diff --git a/core/block_svg.ts b/core/block_svg.ts index ea5dd7da7ed..8ea26e354ef 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -849,6 +849,17 @@ export class BlockSvg Tooltip.dispose(); ContextMenu.hide(); + // If this block was focused, focus its parent or workspace instead. + const focusManager = getFocusManager(); + if (focusManager.getFocusedNode() === this) { + const parent = this.getParent(); + if (parent) { + focusManager.focusNode(parent); + } else { + setTimeout(() => focusManager.focusTree(this.workspace), 0); + } + } + if (animate) { this.unplug(healStack); blockAnimations.disposeUiEffect(this); diff --git a/core/blockly.ts b/core/blockly.ts index 14383a947a3..99112d790fb 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -173,6 +173,10 @@ import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js'; import * as internalConstants from './internal_constants.js'; import {LineCursor} from './keyboard_nav/line_cursor.js'; import {Marker} from './keyboard_nav/marker.js'; +import { + KeyboardNavigationController, + keyboardNavigationController, +} from './keyboard_navigation_controller.js'; import type {LayerManager} from './layer_manager.js'; import * as layers from './layers.js'; import {MarkerManager} from './marker_manager.js'; @@ -580,6 +584,7 @@ export { ImageProperties, Input, InsertionMarkerPreviewer, + KeyboardNavigationController, LabelFlyoutInflater, LayerManager, Marker, @@ -631,6 +636,7 @@ export { isSelectable, isSerializable, isVariableBackedParameterModel, + keyboardNavigationController, layers, renderManagement, serialization, diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 64060fe7888..20e730abb18 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -98,8 +98,8 @@ export abstract class Bubble implements IBubble, ISelectable { * when automatically positioning. * @param overriddenFocusableElement An optional replacement to the focusable * element that's represented by this bubble (as a focusable node). This - * element will have its ID and tabindex overwritten. If not provided, the - * focusable element of this node will default to the bubble's SVG root. + * element will have its ID overwritten. If not provided, the focusable + * element of this node will default to the bubble's SVG root. */ constructor( public readonly workspace: WorkspaceSvg, @@ -138,7 +138,6 @@ export abstract class Bubble implements IBubble, ISelectable { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); - this.focusableElement.setAttribute('tabindex', '-1'); browserEvents.conditionalBind( this.background, diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 76eeb64f16a..3a3d57a441d 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -19,6 +19,7 @@ import {IContextMenu} from '../interfaces/i_contextmenu.js'; import {ICopyable} from '../interfaces/i_copyable.js'; import {IDeletable} from '../interfaces/i_deletable.js'; import {IDraggable} from '../interfaces/i_draggable.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import {ISelectable} from '../interfaces/i_selectable.js'; @@ -42,7 +43,8 @@ export class RenderedWorkspaceComment ISelectable, IDeletable, ICopyable, - IContextMenu + IContextMenu, + IFocusableNode { /** The class encompassing the svg elements making up the workspace comment. */ private view: CommentView; @@ -63,7 +65,6 @@ export class RenderedWorkspaceComment this.view.setEditable(this.isEditable()); this.view.getSvgRoot().setAttribute('data-id', this.id); this.view.getSvgRoot().setAttribute('id', this.id); - this.view.getSvgRoot().setAttribute('tabindex', '-1'); this.addModelUpdateBindings(); @@ -207,7 +208,12 @@ export class RenderedWorkspaceComment /** Disposes of the view. */ override dispose() { this.disposing = true; + const focusManager = getFocusManager(); + if (focusManager.getFocusedNode() === this) { + setTimeout(() => focusManager.focusTree(this.workspace), 0); + } if (!this.view.isDeadOrDying()) this.view.dispose(); + super.dispose(); } diff --git a/core/common.ts b/core/common.ts index 1f7ba7e88df..a4b198ae490 100644 --- a/core/common.ts +++ b/core/common.ts @@ -8,11 +8,13 @@ import type {Block} from './block.js'; import {BlockDefinition, Blocks} from './blocks.js'; +import * as browserEvents from './browser_events.js'; import type {Connection} from './connection.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; import {ISelectable, isSelectable} from './interfaces/i_selectable.js'; +import {ShortcutRegistry} from './shortcut_registry.js'; import type {Workspace} from './workspace.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -310,4 +312,29 @@ export function defineBlocks(blocks: {[key: string]: BlockDefinition}) { } } +/** + * Handle a key-down on SVG drawing surface. Does nothing if the main workspace + * is not visible. + * + * @internal + * @param e Key down event. + */ +export function globalShortcutHandler(e: KeyboardEvent) { + const mainWorkspace = getMainWorkspace() as WorkspaceSvg; + if (!mainWorkspace) { + return; + } + + if ( + browserEvents.isTargetInput(e) || + (mainWorkspace.rendered && !mainWorkspace.isVisible()) + ) { + // When focused on an HTML text input widget, don't trap any keys. + // Ignore keypresses on rendered workspaces that have been explicitly + // hidden. + return; + } + ShortcutRegistry.registry.onKeyDown(mainWorkspace, e); +} + export const TEST_ONLY = {defineBlocksWithJsonArrayInternal}; diff --git a/core/css.ts b/core/css.ts index 6b5e19a585b..4f4a4daaf90 100644 --- a/core/css.ts +++ b/core/css.ts @@ -505,6 +505,6 @@ input[type=number] { .blocklyIconGroup, .blocklyTextarea ) { - outline-width: 0px; + outline: none; } `; diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 894724d4448..ceab467a895 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -13,6 +13,7 @@ // Former goog.module ID: Blockly.dropDownDiv import type {BlockSvg} from './block_svg.js'; +import * as browserEvents from './browser_events.js'; import * as common from './common.js'; import type {Field} from './field.js'; import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; @@ -86,6 +87,9 @@ let positionToField: boolean | null = null; /** Callback to FocusManager to return ephemeral focus when the div closes. */ let returnEphemeralFocus: ReturnEphemeralFocus | null = null; +/** Identifier for shortcut keydown listener used to unbind it. */ +let keydownListener: browserEvents.Data | null = null; + /** * Dropdown bounds info object used to encapsulate sizing information about a * bounding element (bounding box and width/height). @@ -122,6 +126,7 @@ export function createDom() { } div = document.createElement('div'); div.className = 'blocklyDropDownDiv'; + div.tabIndex = -1; const parentDiv = common.getParentContainer() || document.body; parentDiv.appendChild(div); @@ -129,6 +134,13 @@ export function createDom() { content.className = 'blocklyDropDownContent'; div.appendChild(content); + keydownListener = browserEvents.conditionalBind( + content, + 'keydown', + null, + common.globalShortcutHandler, + ); + arrow = document.createElement('div'); arrow.className = 'blocklyDropDownArrow'; div.appendChild(arrow); @@ -167,6 +179,10 @@ export function getContentDiv(): HTMLDivElement { /** Clear the content of the drop-down. */ export function clearContent() { + if (keydownListener) { + browserEvents.unbind(keydownListener); + keydownListener = null; + } div.remove(); createDom(); } @@ -192,6 +208,11 @@ export function setColour(backgroundColour: string, borderColour: string) { * @param block Block to position the drop-down around. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -199,10 +220,12 @@ export function showPositionedByBlock( block: BlockSvg, opt_onHide?: () => void, opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, + manageEphemeralFocus, opt_onHide, opt_secondaryYOffset, ); @@ -217,17 +240,24 @@ export function showPositionedByBlock( * @param field The field to position the dropdown against. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. + * @param manageEphemeralFocus Whether ephemeral focus should be managed + * according to the drop-down div's lifetime. Note that if a false value is + * passed in here then callers should manage ephemeral focus directly + * otherwise focus may not properly restore when the widget closes. Defaults + * to true. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( field: Field, opt_onHide?: () => void, opt_secondaryYOffset?: number, + manageEphemeralFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, + manageEphemeralFocus, opt_onHide, opt_secondaryYOffset, ); @@ -271,16 +301,15 @@ function getScaledBboxOfField(field: Field): Rect { * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly - * otherwise focus may not properly restore when the widget closes. Defaults - * to true. + * otherwise focus may not properly restore when the widget closes. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, + manageEphemeralFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, - manageEphemeralFocus: boolean = true, ): boolean { // If we can fit it, render below the block. const primaryX = bBox.left + (bBox.right - bBox.left) / 2; @@ -352,10 +381,6 @@ export function show( dom.addClass(div, renderedClassName); dom.addClass(div, themeClassName); - if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); - } - // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. // Since we want the translation to initial X, Y to be immediate, @@ -364,7 +389,15 @@ export function show( // making the dropdown appear to fly in from (0, 0). // Using both `left`, `top` for the initial translation and then `translate` // for the animated transition to final X, Y is a workaround. - return positionInternal(primaryX, primaryY, secondaryX, secondaryY); + const atOrigin = positionInternal(primaryX, primaryY, secondaryX, secondaryY); + + // Ephemeral focus must happen after the div is fully visible in order to + // ensure that it properly receives focus. + if (manageEphemeralFocus) { + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + } + + return atOrigin; } const internal = { diff --git a/core/field.ts b/core/field.ts index f7e01527e5d..c4b6514785e 100644 --- a/core/field.ts +++ b/core/field.ts @@ -312,7 +312,6 @@ export abstract class Field const id = this.id_; if (!id) throw new Error('Expected ID to be defined prior to init.'); this.fieldGroup_ = dom.createSvgElement(Svg.G, { - 'tabindex': '-1', 'id': id, }); if (!this.isVisible()) { diff --git a/core/field_image.ts b/core/field_image.ts index 6dfe2530e50..01133c20340 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -212,6 +212,17 @@ export class FieldImage extends Field { } } + /** + * Check whether this field should be clickable. + * + * @returns Whether this field is clickable. + */ + isClickable(): boolean { + // Images are only clickable if they have a click handler and fulfill the + // contract to be clickable: enabled and attached to an editable block. + return super.isClickable() && !!this.clickHandler; + } + /** * If field click is called, and click handler defined, * call the handler. diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 9f94ec30905..492d3341762 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -22,7 +22,6 @@ import {FlyoutItem} from './flyout_item.js'; import {FlyoutMetricsManager} from './flyout_metrics_manager.js'; import {FlyoutNavigator} from './flyout_navigator.js'; import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js'; -import {getFocusManager} from './focus_manager.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; @@ -308,7 +307,6 @@ export abstract class Flyout // hide/show code will set up proper visibility and size later. this.svgGroup_ = dom.createSvgElement(tagName, { 'class': 'blocklyFlyout', - 'tabindex': '0', }); this.svgGroup_.style.display = 'none'; this.svgBackground_ = dom.createSvgElement( @@ -324,8 +322,6 @@ export abstract class Flyout .getThemeManager() .subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); - getFocusManager().registerTree(this); - return this.svgGroup_; } @@ -407,7 +403,6 @@ export abstract class Flyout if (this.svgGroup_) { dom.removeNode(this.svgGroup_); } - getFocusManager().unregisterTree(this); } /** @@ -971,15 +966,22 @@ export abstract class Flyout return null; } - /** See IFocusableNode.getFocusableElement. */ + /** + * See IFocusableNode.getFocusableElement. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getFocusableElement(): HTMLElement | SVGElement { - if (!this.svgGroup_) throw new Error('Flyout DOM is not yet created.'); - return this.svgGroup_; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableNode.getFocusableTree. */ + /** + * See IFocusableNode.getFocusableTree. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getFocusableTree(): IFocusableTree { - return this; + throw new Error('Flyouts are not directly focusable.'); } /** See IFocusableNode.onNodeFocus. */ @@ -990,31 +992,45 @@ export abstract class Flyout /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + return false; } - /** See IFocusableTree.getRootFocusableNode. */ + /** + * See IFocusableNode.getRootFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getRootFocusableNode(): IFocusableNode { - return this; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.getRestoredFocusableNode. */ + /** + * See IFocusableNode.getRestoredFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getRestoredFocusableNode( _previousNode: IFocusableNode | null, ): IFocusableNode | null { - return null; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.getNestedTrees. */ + /** + * See IFocusableNode.getNestedTrees. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ getNestedTrees(): Array { - return [this.workspace_]; + throw new Error('Flyouts are not directly focusable.'); } - /** See IFocusableTree.lookUpFocusableNode. */ + /** + * See IFocusableNode.lookUpFocusableNode. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ lookUpFocusableNode(_id: string): IFocusableNode | null { - // No focusable node needs to be returned since the flyout's subtree is a - // workspace that will manage its own focusable state. - return null; + throw new Error('Flyouts are not directly focusable.'); } /** See IFocusableTree.onTreeFocus. */ @@ -1023,15 +1039,12 @@ export abstract class Flyout _previousTree: IFocusableTree | null, ): void {} - /** See IFocusableTree.onTreeBlur. */ - onTreeBlur(nextTree: IFocusableTree | null): void { - const toolbox = this.targetWorkspace.getToolbox(); - // If focus is moving to either the toolbox or the flyout's workspace, do - // not close the flyout. For anything else, do close it since the flyout is - // no longer focused. - if (toolbox && nextTree === toolbox) return; - if (nextTree === this.workspace_) return; - if (toolbox) toolbox.clearSelection(); - this.autoHide(false); + /** + * See IFocusableNode.onTreeBlur. + * + * @deprecated v12: Use the Flyout's workspace for focus operations, instead. + */ + onTreeBlur(_nextTree: IFocusableTree | null): void { + throw new Error('Flyouts are not directly focusable.'); } } diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 823b57be765..c9afb8b0159 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -113,7 +113,7 @@ export class FlyoutButton this.id = idGenerator.getNextUniqueId(); this.svgGroup = dom.createSvgElement( Svg.G, - {'id': this.id, 'class': cssClass, 'tabindex': '-1'}, + {'id': this.id, 'class': cssClass}, this.workspace.getCanvas(), ); diff --git a/core/focus_manager.ts b/core/focus_manager.ts index c0139aec08d..3d0a9347f85 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,6 +17,24 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; +/** + * Represents an IFocusableTree that has been registered for focus management in + * FocusManager. + */ +class TreeRegistration { + /** + * Constructs a new TreeRegistration. + * + * @param tree The tree being registered. + * @param rootShouldBeAutoTabbable Whether the tree should have automatic + * top-level tab management. + */ + constructor( + readonly tree: IFocusableTree, + readonly rootShouldBeAutoTabbable: boolean, + ) {} +} + /** * A per-page singleton that manages Blockly focus across one or more * IFocusableTrees, and bidirectionally synchronizes this focus with the DOM. @@ -58,24 +76,29 @@ export class FocusManager { private focusedNode: IFocusableNode | null = null; private previouslyFocusedNode: IFocusableNode | null = null; - private registeredTrees: Array = []; + private registeredTrees: Array = []; private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; + private isUpdatingFocusedNode: boolean = false; constructor( addGlobalEventListener: (type: string, listener: EventListener) => void, ) { // Note that 'element' here is the element *gaining* focus. const maybeFocus = (element: Element | EventTarget | null) => { + // Skip processing the event if the focused node is currently updating. + if (this.isUpdatingFocusedNode) return; + this.recentlyLostAllFocus = !element; let newNode: IFocusableNode | null | undefined = null; if (element instanceof HTMLElement || element instanceof SVGElement) { // If the target losing or gaining focus maps to any tree, then it // should be updated. Per the contract of findFocusableNodeFor only one // tree should claim the element, so the search can be exited early. - for (const tree of this.registeredTrees) { + for (const reg of this.registeredTrees) { + const tree = reg.tree; newNode = FocusableTreeTraverser.findFocusableNodeFor(element, tree); if (newNode) break; } @@ -128,13 +151,32 @@ export class FocusManager { * This function throws if the provided tree is already currently registered * in this manager. Use isRegistered to check in cases when it can't be * certain whether the tree has been registered. + * + * The tree's registration can be customized to configure automatic tab stops. + * This specifically provides capability for the user to be able to tab + * navigate to the root of the tree but only when the tree doesn't hold active + * focus. If this functionality is disabled then the tree's root will + * automatically be made focusable (but not tabbable) when it is first focused + * in the same way as any other focusable node. + * + * @param tree The IFocusableTree to register. + * @param rootShouldBeAutoTabbable Whether the root of this tree should be + * added as a top-level page tab stop when it doesn't hold active focus. */ - registerTree(tree: IFocusableTree): void { + registerTree( + tree: IFocusableTree, + rootShouldBeAutoTabbable: boolean = false, + ): void { this.ensureManagerIsUnlocked(); if (this.isRegistered(tree)) { throw Error(`Attempted to re-register already registered tree: ${tree}.`); } - this.registeredTrees.push(tree); + this.registeredTrees.push( + new TreeRegistration(tree, rootShouldBeAutoTabbable), + ); + if (rootShouldBeAutoTabbable) { + tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + } } /** @@ -143,7 +185,15 @@ export class FocusManager { * unregisterTree. */ isRegistered(tree: IFocusableTree): boolean { - return this.registeredTrees.findIndex((reg) => reg === tree) !== -1; + return !!this.lookUpRegistration(tree); + } + + /** + * Returns the TreeRegistration for the specified tree, or null if the tree is + * not currently registered. + */ + private lookUpRegistration(tree: IFocusableTree): TreeRegistration | null { + return this.registeredTrees.find((reg) => reg.tree === tree) ?? null; } /** @@ -154,13 +204,19 @@ export class FocusManager { * * This function throws if the provided tree is not currently registered in * this manager. + * + * This function will reset the tree's root element tabindex if the tree was + * registered with automatic tab management. */ unregisterTree(tree: IFocusableTree): void { this.ensureManagerIsUnlocked(); if (!this.isRegistered(tree)) { throw Error(`Attempted to unregister not registered tree: ${tree}.`); } - const treeIndex = this.registeredTrees.findIndex((reg) => reg === tree); + const treeIndex = this.registeredTrees.findIndex( + (reg) => reg.tree === tree, + ); + const registration = this.registeredTrees[treeIndex]; this.registeredTrees.splice(treeIndex, 1); const focusedNode = FocusableTreeTraverser.findFocusedNode(tree); @@ -170,6 +226,13 @@ export class FocusManager { this.updateFocusedNode(null); } this.removeHighlight(root); + + if (registration.rootShouldBeAutoTabbable) { + tree + .getRootFocusableNode() + .getFocusableElement() + .removeAttribute('tabindex'); + } } /** @@ -236,14 +299,43 @@ export class FocusManager { * canBeFocused() method returns false), it will be ignored and any existing * focus state will remain unchanged. * + * Note that this may update the specified node's element's tabindex to ensure + * that it can be properly read out by screenreaders while focused. + * * @param focusableNode The node that should receive active focus. */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - if (this.focusedNode === focusableNode) return; // State is unchanged. + const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + if (mustRestoreUpdatingNode) { + // Disable state syncing from DOM events since possible calls to focus() + // below will loop a call back to focusNode(). + this.isUpdatingFocusedNode = true; + } + + // Double check that state wasn't desynchronized in the background. See: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + // This is only done for the case where the same node is being focused twice + // since other cases should automatically correct (due to the rest of the + // routine running as normal). + const prevFocusedElement = this.focusedNode?.getFocusableElement(); + const hasDesyncedState = prevFocusedElement !== document.activeElement; + if (this.focusedNode === focusableNode && !hasDesyncedState) { + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } + return; // State is unchanged. + } + if (!focusableNode.canBeFocused()) { // This node can't be focused. console.warn("Trying to focus a node that can't be focused."); + + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } return; } @@ -292,6 +384,10 @@ export class FocusManager { this.activelyFocusNode(nodeToFocus, prevTree ?? null); } this.updateFocusedNode(nodeToFocus); + if (mustRestoreUpdatingNode) { + // Reenable state syncing from DOM events. + this.isUpdatingFocusedNode = false; + } } /** @@ -362,6 +458,13 @@ export class FocusManager { }; } + /** + * @returns whether something is currently holding ephemeral focus + */ + ephemeralFocusTaken(): boolean { + return this.currentlyHoldsEphemeralFocus; + } + /** * Ensures that the manager is currently allowing operations that change its * internal focus state (such as via focusNode()). @@ -424,14 +527,38 @@ export class FocusManager { // node's focusable element (which *is* allowed to be invisible until the // node needs to be focused). this.lockFocusStateChanges = true; - if (node.getFocusableTree() !== prevTree) { - node.getFocusableTree().onTreeFocus(node, prevTree); + const tree = node.getFocusableTree(); + const elem = node.getFocusableElement(); + const nextTreeReg = this.lookUpRegistration(tree); + const treeIsTabManaged = nextTreeReg?.rootShouldBeAutoTabbable; + if (tree !== prevTree) { + tree.onTreeFocus(node, prevTree); + + if (treeIsTabManaged) { + // If this node's tree has its tab auto-managed, ensure that it's no + // longer tabbable now that it holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = -1; + } } node.onNodeFocus(); this.lockFocusStateChanges = false; + // The tab index should be set in all cases where: + // - It doesn't overwrite an pre-set tab index for the node. + // - The node is part of a tree whose tab index is unmanaged. + // OR + // - The node is part of a managed tree but this isn't the root. Managed + // roots are ignored since they are always overwritten to have a tab index + // of -1 with active focus so that they cannot be tab navigated. + // + // Setting the tab index ensures that the node's focusable element can + // actually receive DOM focus. + if (!treeIsTabManaged || node !== tree.getRootFocusableNode()) { + if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1; + } + this.setNodeToVisualActiveFocus(node); - node.getFocusableElement().focus(); + elem.focus(); } /** @@ -451,13 +578,21 @@ export class FocusManager { nextTree: IFocusableTree | null, ): void { this.lockFocusStateChanges = true; - if (node.getFocusableTree() !== nextTree) { - node.getFocusableTree().onTreeBlur(nextTree); + const tree = node.getFocusableTree(); + if (tree !== nextTree) { + tree.onTreeBlur(nextTree); + + const reg = this.lookUpRegistration(tree); + if (reg?.rootShouldBeAutoTabbable) { + // If this node's tree has its tab auto-managed, ensure that it's now + // tabbable since it no longer holds active focus. + tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + } } node.onNodeBlur(); this.lockFocusStateChanges = false; - if (node.getFocusableTree() !== nextTree) { + if (tree !== nextTree) { this.setNodeToVisualPassiveFocus(node); } } diff --git a/core/gesture.ts b/core/gesture.ts index f9b435c67d9..4c65c1d3842 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -31,6 +31,7 @@ import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {IDragger} from './interfaces/i_dragger.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IIcon} from './interfaces/i_icon.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import * as registry from './registry.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; @@ -541,8 +542,10 @@ export class Gesture { // have higher priority than workspaces. The ordering within drags does // not matter, because the three types of dragging are exclusive. if (this.dragger) { + keyboardNavigationController.setIsActive(false); this.dragger.onDragEnd(e, this.currentDragDeltaXY); } else if (this.workspaceDragger) { + keyboardNavigationController.setIsActive(false); this.workspaceDragger.endDrag(this.currentDragDeltaXY); } else if (this.isBubbleClick()) { // Do nothing, bubbles don't currently respond to clicks. @@ -743,6 +746,8 @@ export class Gesture { e.preventDefault(); e.stopPropagation(); + keyboardNavigationController.setIsActive(false); + this.dispose(); } diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 5bf61d49c91..8f8ff70fc32 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -59,7 +59,6 @@ export abstract class Icon implements IIcon { const svgBlock = this.sourceBlock as BlockSvg; this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyIconGroup', - 'tabindex': '-1', 'id': this.id, }); svgBlock.getSvgRoot().appendChild(this.svgRoot); @@ -178,4 +177,13 @@ export abstract class Icon implements IIcon { canBeFocused(): boolean { return true; } + + /** + * Returns the block that this icon is attached to. + * + * @returns The block this icon is attached to. + */ + getSourceBlock(): Block { + return this.sourceBlock; + } } diff --git a/core/inject.ts b/core/inject.ts index 34d9c1795f8..4217c515119 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -15,7 +15,6 @@ import * as dropDownDiv from './dropdowndiv.js'; import {Grid} from './grid.js'; import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; -import {ShortcutRegistry} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; import * as dom from './utils/dom.js'; @@ -72,17 +71,12 @@ export function inject( common.setMainWorkspace(workspace); }); - browserEvents.conditionalBind(subContainer, 'keydown', null, onKeyDown); browserEvents.conditionalBind( - dropDownDiv.getContentDiv(), + subContainer, 'keydown', null, - onKeyDown, + common.globalShortcutHandler, ); - const widgetContainer = WidgetDiv.getDiv(); - if (widgetContainer) { - browserEvents.conditionalBind(widgetContainer, 'keydown', null, onKeyDown); - } return workspace; } @@ -292,32 +286,6 @@ function init(mainWorkspace: WorkspaceSvg) { } } -/** - * Handle a key-down on SVG drawing surface. Does nothing if the main workspace - * is not visible. - * - * @param e Key down event. - */ -// TODO (https://github.com/google/blockly/issues/1998) handle cases where there -// are multiple workspaces and non-main workspaces are able to accept input. -function onKeyDown(e: KeyboardEvent) { - const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; - if (!mainWorkspace) { - return; - } - - if ( - browserEvents.isTargetInput(e) || - (mainWorkspace.rendered && !mainWorkspace.isVisible()) - ) { - // When focused on an HTML text input widget, don't trap any keys. - // Ignore keypresses on rendered workspaces that have been explicitly - // hidden. - return; - } - ShortcutRegistry.registry.onKeyDown(mainWorkspace, e); -} - /** * Whether event handlers have been bound. Document event handlers will only * be bound once, even if Blockly is destroyed and reinjected. diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts index b21d7741a5c..00557168afa 100644 --- a/core/interfaces/i_focusable_node.ts +++ b/core/interfaces/i_focusable_node.ts @@ -19,13 +19,11 @@ export interface IFocusableNode { * - blocklyActiveFocus * - blocklyPassiveFocus * - * The returned element must also have a valid ID specified, and unique across - * the entire page. Failing to have a properly unique ID could result in - * trying to focus one node (such as via a mouse click) leading to another - * node with the same ID actually becoming focused by FocusManager. The - * returned element must also have a negative tabindex (since the focus - * manager itself will manage its tab index and a tab index must be present in - * order for the element to be focusable in the DOM). + * The returned element must also have a valid ID specified, and this ID + * should be unique across the entire page. Failing to have a properly unique + * ID could result in trying to focus one node (such as via a mouse click) + * leading to another node with the same ID actually becoming focused by + * FocusManager. * * The returned element must be visible if the node is ever focused via * FocusManager.focusNode() or FocusManager.focusTree(). It's allowed for an @@ -34,7 +32,11 @@ export interface IFocusableNode { * * It's expected the actual returned element will not change for the lifetime * of the node (that is, its properties can change but a new element should - * never be returned). + * never be returned). Also, the returned element will have its tabindex + * overwritten throughout the lifecycle of this node and FocusManager. + * + * If a node requires the ability to be focused directly without first being + * focused via FocusManager then it must set its own tab index. * * @returns The HTMLElement or SVGElement which can both receive focus and be * visually represented as actively or passively focused for this node. diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 74f970d9961..570b06fe392 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -5,9 +5,12 @@ */ import {BlockSvg} from '../block_svg.js'; +import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; +import type {Icon} from '../icons/icon.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {RenderedConnection} from '../rendered_connection.js'; import {WorkspaceSvg} from '../workspace_svg.js'; /** @@ -21,15 +24,8 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { - for (const input of current.inputList) { - for (const field of input.fieldRow) { - return field; - } - if (input.connection?.targetBlock()) - return input.connection.targetBlock() as BlockSvg; - } - - return null; + const candidates = getBlockNavigationCandidates(current); + return candidates[0]; } /** @@ -54,41 +50,16 @@ export class BlockNavigationPolicy implements INavigationPolicy { * Returns the next peer node of the given block. * * @param current The block to find the following element of. - * @returns The first block of the next stack if the given block is a terminal + * @returns The first node of the next input/stack if the given block is a terminal * block, or its next connection. */ getNextSibling(current: BlockSvg): IFocusableNode | null { if (current.nextConnection?.targetBlock()) { return current.nextConnection?.targetBlock(); - } - - const parent = this.getParent(current); - let navigatingCrossStacks = false; - let siblings: (BlockSvg | Field)[] = []; - if (parent instanceof BlockSvg) { - for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (input.connection) { - siblings.push(...input.fieldRow); - const child = input.connection.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } - } - } - } else if (parent instanceof WorkspaceSvg) { - siblings = parent.getTopBlocks(true); - navigatingCrossStacks = true; - } else { - return null; - } - - const currentIndex = siblings.indexOf( - navigatingCrossStacks ? current.getRootBlock() : current, - ); - if (currentIndex >= 0 && currentIndex < siblings.length - 1) { - return siblings[currentIndex + 1]; - } else if (currentIndex === siblings.length - 1 && navigatingCrossStacks) { - return siblings[0]; + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock(current, 1); + } else if (this.getParent(current) instanceof WorkspaceSvg) { + return navigateStacks(current, 1); } return null; @@ -104,43 +75,13 @@ export class BlockNavigationPolicy implements INavigationPolicy { getPreviousSibling(current: BlockSvg): IFocusableNode | null { if (current.previousConnection?.targetBlock()) { return current.previousConnection?.targetBlock(); + } else if (current.outputConnection?.targetBlock()) { + return navigateBlock(current, -1); + } else if (this.getParent(current) instanceof WorkspaceSvg) { + return navigateStacks(current, -1); } - const parent = this.getParent(current); - let navigatingCrossStacks = false; - let siblings: (BlockSvg | Field)[] = []; - if (parent instanceof BlockSvg) { - for (let i = 0, input; (input = parent.inputList[i]); i++) { - if (input.connection) { - siblings.push(...input.fieldRow); - const child = input.connection.targetBlock(); - if (child) { - siblings.push(child as BlockSvg); - } - } - } - } else if (parent instanceof WorkspaceSvg) { - siblings = parent.getTopBlocks(true); - navigatingCrossStacks = true; - } else { - return null; - } - - const currentIndex = siblings.indexOf(current); - let result: IFocusableNode | null = null; - if (currentIndex >= 1) { - result = siblings[currentIndex - 1]; - } else if (currentIndex === 0 && navigatingCrossStacks) { - result = siblings[siblings.length - 1]; - } - - // If navigating to a previous stack, our previous sibling is the last - // block in it. - if (navigatingCrossStacks && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; - } - - return result; + return null; } /** @@ -163,3 +104,88 @@ export class BlockNavigationPolicy implements INavigationPolicy { return current instanceof BlockSvg; } } + +/** + * Returns a list of the navigable children of the given block. + * + * @param block The block to retrieve the navigable children of. + * @returns A list of navigable/focusable children of the given block. + */ +function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { + const candidates: IFocusableNode[] = block.getIcons(); + + for (const input of block.inputList) { + if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); + if (input.connection?.targetBlock()) { + candidates.push(input.connection.targetBlock() as BlockSvg); + } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { + candidates.push(input.connection as RenderedConnection); + } + } + + return candidates; +} + +/** + * Returns the next/previous stack relative to the given block's stack. + * + * @param current The block whose stack will be navigated relative to. + * @param delta The difference in index to navigate; positive values navigate + * to the nth next stack, while negative values navigate to the nth previous + * stack. + * @returns The first block in the stack offset by `delta` relative to the + * current block's stack, or the last block in the stack offset by `delta` + * relative to the current block's stack when navigating backwards. + */ +export function navigateStacks(current: BlockSvg, delta: number) { + const stacks = current.workspace.getTopBlocks(true); + const currentIndex = stacks.indexOf(current.getRootBlock()); + const targetIndex = currentIndex + delta; + let result: BlockSvg | null = null; + if (targetIndex >= 0 && targetIndex < stacks.length) { + result = stacks[targetIndex]; + } else if (targetIndex < 0) { + result = stacks[stacks.length - 1]; + } else if (targetIndex >= stacks.length) { + result = stacks[0]; + } + + // When navigating to a previous stack, our previous sibling is the last + // block in it. + if (delta < 0 && result) { + return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + } + + return result; +} + +/** + * Returns the next navigable item relative to the provided block child. + * + * @param current The navigable block child item to navigate relative to. + * @param delta The difference in index to navigate; positive values navigate + * forward by n, while negative values navigate backwards by n. + * @returns The navigable block child offset by `delta` relative to `current`. + */ +export function navigateBlock( + current: Icon | Field | RenderedConnection | BlockSvg, + delta: number, +): IFocusableNode | null { + const block = + current instanceof BlockSvg + ? current.outputConnection.targetBlock() + : current.getSourceBlock(); + if (!(block instanceof BlockSvg)) return null; + + const candidates = getBlockNavigationCandidates(block); + const currentIndex = candidates.indexOf(current); + if (currentIndex === -1) return null; + + const targetIndex = currentIndex + delta; + if (targetIndex >= 0 && targetIndex < candidates.length) { + return candidates[targetIndex]; + } + + return null; +} diff --git a/core/keyboard_nav/connection_navigation_policy.ts b/core/keyboard_nav/connection_navigation_policy.ts index 9c3eafc56b0..bf685d0635c 100644 --- a/core/keyboard_nav/connection_navigation_policy.ts +++ b/core/keyboard_nav/connection_navigation_policy.ts @@ -9,6 +9,7 @@ import {ConnectionType} from '../connection_type.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; import {RenderedConnection} from '../rendered_connection.js'; +import {navigateBlock} from './block_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a connection. @@ -37,17 +38,7 @@ export class ConnectionNavigationPolicy * @returns The given connection's parent connection or block. */ getParent(current: RenderedConnection): IFocusableNode | null { - if (current.type === ConnectionType.OUTPUT_VALUE) { - return current.targetConnection ?? current.getSourceBlock(); - } else if (current.getParentInput()) { - return current.getSourceBlock(); - } - - const topBlock = current.getSourceBlock().getTopStackBlock(); - return ( - (this.getParentConnection(topBlock)?.targetConnection?.getParentInput() - ?.connection as RenderedConnection) ?? topBlock - ); + return current.getSourceBlock(); } /** @@ -58,19 +49,7 @@ export class ConnectionNavigationPolicy */ getNextSibling(current: RenderedConnection): IFocusableNode | null { if (current.getParentInput()) { - const parentInput = current.getParentInput(); - const block = parentInput?.getSourceBlock(); - if (!block || !parentInput) return null; - - const curIdx = block.inputList.indexOf(parentInput); - for (let i = curIdx + 1; i < block.inputList.length; i++) { - const input = block.inputList[i]; - const fieldRow = input.fieldRow; - if (fieldRow.length) return fieldRow[0]; - if (input.connection) return input.connection as RenderedConnection; - } - - return null; + return navigateBlock(current, 1); } else if (current.type === ConnectionType.NEXT_STATEMENT) { const nextBlock = current.targetConnection; // If this connection is the last one in the stack, our next sibling is @@ -103,20 +82,7 @@ export class ConnectionNavigationPolicy */ getPreviousSibling(current: RenderedConnection): IFocusableNode | null { if (current.getParentInput()) { - const parentInput = current.getParentInput(); - const block = parentInput?.getSourceBlock(); - if (!block || !parentInput) return null; - - const curIdx = block.inputList.indexOf(parentInput); - for (let i = curIdx; i >= 0; i--) { - const input = block.inputList[i]; - if (input.connection && input !== parentInput) { - return input.connection as RenderedConnection; - } - const fieldRow = input.fieldRow; - if (fieldRow.length) return fieldRow[fieldRow.length - 1]; - } - return null; + return navigateBlock(current, -1); } else if ( current.type === ConnectionType.PREVIOUS_STATEMENT || current.type === ConnectionType.OUTPUT_VALUE diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index 3b88dc9248b..f9df406c22c 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -8,6 +8,7 @@ import type {BlockSvg} from '../block_svg.js'; import {Field} from '../field.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateBlock} from './block_navigation_policy.js'; /** * Set of rules controlling keyboard navigation from a field. @@ -40,22 +41,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The next field or input in the given field's block. */ getNextSibling(current: Field): IFocusableNode | null { - const input = current.getParentInput(); - const block = current.getSourceBlock(); - if (!block) return null; - - const curIdx = block.inputList.indexOf(input); - let fieldIdx = input.fieldRow.indexOf(current) + 1; - for (let i = curIdx; i < block.inputList.length; i++) { - const newInput = block.inputList[i]; - const fieldRow = newInput.fieldRow; - if (fieldIdx < fieldRow.length) return fieldRow[fieldIdx]; - fieldIdx = 0; - if (newInput.connection?.targetBlock()) { - return newInput.connection.targetBlock() as BlockSvg; - } - } - return null; + return navigateBlock(current, 1); } /** @@ -65,27 +51,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { * @returns The preceding field or input in the given field's block. */ getPreviousSibling(current: Field): IFocusableNode | null { - const parentInput = current.getParentInput(); - const block = current.getSourceBlock(); - if (!block) return null; - - const curIdx = block.inputList.indexOf(parentInput); - let fieldIdx = parentInput.fieldRow.indexOf(current) - 1; - for (let i = curIdx; i >= 0; i--) { - const input = block.inputList[i]; - if (input.connection?.targetBlock() && input !== parentInput) { - return input.connection.targetBlock() as BlockSvg; - } - const fieldRow = input.fieldRow; - if (fieldIdx > -1) return fieldRow[fieldIdx]; - - // Reset the fieldIdx to the length of the field row of the previous - // input. - if (i - 1 >= 0) { - fieldIdx = block.inputList[i - 1].fieldRow.length - 1; - } - } - return null; + return navigateBlock(current, -1); } /** @@ -97,6 +63,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { isNavigable(current: Field): boolean { return ( current.canBeFocused() && + current.isVisible() && (current.isClickable() || current.isCurrentlyEditable()) && !( current.getSourceBlock()?.isSimpleReporter() && diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts new file mode 100644 index 00000000000..96908cbbdf8 --- /dev/null +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {BlockSvg} from '../block_svg.js'; +import {Icon} from '../icons/icon.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateBlock} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an icon. + */ +export class IconNavigationPolicy implements INavigationPolicy { + /** + * Returns the first child of the given icon. + * + * @param _current The icon to return the first child of. + * @returns Null. + */ + getFirstChild(_current: Icon): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given icon. + * + * @param current The icon to return the parent of. + * @returns The source block of the given icon. + */ + getParent(current: Icon): IFocusableNode | null { + return current.getSourceBlock() as BlockSvg; + } + + /** + * Returns the next peer node of the given icon. + * + * @param current The icon to find the following element of. + * @returns The next icon, field or input following this icon, if any. + */ + getNextSibling(current: Icon): IFocusableNode | null { + return navigateBlock(current, 1); + } + + /** + * Returns the previous peer node of the given icon. + * + * @param current The icon to find the preceding element of. + * @returns The icon's previous icon, if any. + */ + getPreviousSibling(current: Icon): IFocusableNode | null { + return navigateBlock(current, -1); + } + + /** + * Returns whether or not the given icon can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given icon can be focused. + */ + isNavigable(current: Icon): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an Icon. + */ + isApplicable(current: any): current is Icon { + return current instanceof Icon; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 9d83f6554d3..85c0f414a07 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -14,6 +14,7 @@ */ import {BlockSvg} from '../block_svg.js'; +import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {isFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -377,7 +378,7 @@ export class LineCursor extends Marker { // Ensure the current node matches what's currently focused. const focused = getFocusManager().getFocusedNode(); const block = this.getSourceBlockFromNode(focused); - if (!block || block.workspace === this.workspace) { + if (block && block.workspace === this.workspace) { // If the current focused node corresponds to a block then ensure that it // belongs to the correct workspace for this cursor. this.setCurNode(focused); @@ -406,6 +407,11 @@ export class LineCursor extends Marker { newNode.workspace.scrollBoundsIntoView( newNode.getBoundingRectangleWithoutChildren(), ); + } else if (newNode instanceof Field) { + const block = newNode.getSourceBlock() as BlockSvg; + block.workspace.scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + ); } } diff --git a/core/keyboard_navigation_controller.ts b/core/keyboard_navigation_controller.ts new file mode 100644 index 00000000000..d0a766daff2 --- /dev/null +++ b/core/keyboard_navigation_controller.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The KeyboardNavigationController handles coordinating Blockly-wide + * keyboard navigation behavior, such as enabling/disabling full + * cursor visualization. + */ +export class KeyboardNavigationController { + /** Whether the user is actively using keyboard navigation. */ + private isActive = false; + /** Css class name added to body if keyboard nav is active. */ + private activeClassName = 'blocklyKeyboardNavigation'; + + /** + * Sets whether a user is actively using keyboard navigation. + * + * If they are, apply a css class to the entire page so that + * focused items can apply additional styling for keyboard users. + * + * Note that since enabling keyboard navigation presents significant UX changes + * (such as cursor visualization and move mode), callers should take care to + * only set active keyboard navigation when they have a high confidence in that + * being the correct state. In general, in any given mouse or key input situation + * callers can choose one of three paths: + * 1. Do nothing. This should be the choice for neutral actions that don't + * predominantly imply keyboard or mouse usage (such as clicking to select a block). + * 2. Disable keyboard navigation. This is the best choice when a user is definitely + * predominantly using the mouse (such as using a right click to open the context menu). + * 3. Enable keyboard navigation. This is the best choice when there's high confidence + * a user actually intends to use it (such as attempting to use the arrow keys to move + * around). + * + * @param isUsing + */ + setIsActive(isUsing: boolean = true) { + this.isActive = isUsing; + this.updateActiveVisualization(); + } + + /** + * @returns true if the user is actively using keyboard navigation + * (e.g., has recently taken some action that is only relevant to keyboard users) + */ + getIsActive(): boolean { + return this.isActive; + } + + /** Adds or removes the css class that indicates keyboard navigation is active. */ + private updateActiveVisualization() { + if (this.isActive) { + document.body.classList.add(this.activeClassName); + } else { + document.body.classList.remove(this.activeClassName); + } + } +} + +/** Singleton instance of the keyboard navigation controller. */ +export const keyboardNavigationController = new KeyboardNavigationController(); diff --git a/core/layer_manager.ts b/core/layer_manager.ts index 1d5afdd74e9..fd7d8fe235a 100644 --- a/core/layer_manager.ts +++ b/core/layer_manager.ts @@ -104,9 +104,11 @@ export class LayerManager { moveToDragLayer(elem: IRenderedElement & IFocusableNode) { this.dragLayer?.appendChild(elem.getSvgRoot()); - // Since moving the element to the drag layer will cause it to lose focus, - // ensure it regains focus (to ensure proper highlights & sent events). - getFocusManager().focusNode(elem); + if (elem.canBeFocused()) { + // Since moving the element to the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); + } } /** @@ -117,9 +119,11 @@ export class LayerManager { moveOffDragLayer(elem: IRenderedElement & IFocusableNode, layerNum: number) { this.append(elem, layerNum); - // Since moving the element off the drag layer will cause it to lose focus, - // ensure it regains focus (to ensure proper highlights & sent events). - getFocusManager().focusNode(elem); + if (elem.canBeFocused()) { + // Since moving the element off the drag layer will cause it to lose focus, + // ensure it regains focus (to ensure proper highlights & sent events). + getFocusManager().focusNode(elem); + } } /** diff --git a/core/navigator.ts b/core/navigator.ts index 7a1c2d4ea10..92c921122dc 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -9,6 +9,7 @@ import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; +import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -27,6 +28,7 @@ export class Navigator { new FieldNavigationPolicy(), new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), + new IconNavigationPolicy(), ]; /** diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 7efc6318a31..f6291b9f0fa 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -50,7 +50,7 @@ export class PathObject implements IPathObject { /** The primary path of the block. */ this.svgPath = dom.createSvgElement( Svg.PATH, - {'class': 'blocklyPath', 'tabindex': '-1'}, + {'class': 'blocklyPath'}, this.svgRoot, ); @@ -239,7 +239,6 @@ export class PathObject implements IPathObject { 'id': connection.id, 'class': 'blocklyHighlightedConnectionPath', 'style': 'display: none;', - 'tabindex': '-1', 'd': connectionPath, 'transform': transformation, }, diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index a16e22aa33b..161d5fceb13 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -9,14 +9,24 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; -import {ICopyData, isCopyable} from './interfaces/i_copyable.js'; -import {isDeletable} from './interfaces/i_deletable.js'; -import {isDraggable} from './interfaces/i_draggable.js'; +import { + ICopyable, + ICopyData, + isCopyable as isICopyable, +} from './interfaces/i_copyable.js'; +import { + IDeletable, + isDeletable as isIDeletable, +} from './interfaces/i_deletable.js'; +import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; import {Rect} from './utils/rect.js'; +import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; /** @@ -61,9 +71,11 @@ export function registerDelete() { return ( !workspace.isReadOnly() && focused != null && - isDeletable(focused) && + isIDeletable(focused) && focused.isDeletable() && - !Gesture.inProgress() + !Gesture.inProgress() && + // Don't delete the block if a field editor is open + !getFocusManager().ephemeralFocusTaken() ); }, callback(workspace, e, shortcut, scope) { @@ -75,7 +87,7 @@ export function registerDelete() { const focused = scope.focusedNode; if (focused instanceof BlockSvg) { focused.checkAndDelete(); - } else if (isDeletable(focused) && focused.isDeletable()) { + } else if (isIDeletable(focused) && focused.isDeletable()) { eventUtils.setGroup(true); focused.dispose(); eventUtils.setGroup(false); @@ -91,6 +103,73 @@ let copyData: ICopyData | null = null; let copyWorkspace: WorkspaceSvg | null = null; let copyCoords: Coordinate | null = null; +/** + * Determine if a focusable node can be copied. + * + * Unfortunately the ICopyable interface doesn't include an isCopyable + * method, so we must use some other criteria to make the decision. + * Specifically, + * + * - It must be an ICopyable. + * - So that a pasted copy can be manipluated and/or disposed of, it + * must be both an IDraggable and an IDeletable. + * - Additionally, both .isOwnMovable() and .isOwnDeletable() must return + * true (i.e., the copy could be moved and deleted). + * + * TODO(#9098): Revise these criteria. The latter criteria prevents + * shadow blocks from being copied; additionally, there are likely to + * be other circumstances were it is desirable to allow movable / + * copyable copies of a currently-unmovable / -copyable block to be + * made. + * + * @param focused The focused object. + */ +function isCopyable( + focused: IFocusableNode, +): focused is ICopyable & IDeletable & IDraggable { + if (!(focused instanceof BlockSvg)) return false; + return ( + isICopyable(focused) && + isIDeletable(focused) && + focused.isOwnDeletable() && + isDraggable(focused) && + focused.isOwnMovable() + ); +} + +/** + * Determine if a focusable node can be cut. + * + * Unfortunately the ICopyable interface doesn't include an isCuttable + * method, so we must use some other criteria to make the decision. + * Specifically, + * + * - It must be an ICopyable. + * - So that a pasted copy can be manipluated and/or disposed of, it + * must be both an IDraggable and an IDeletable. + * - Additionally, both .isMovable() and .isDeletable() must return + * true (i.e., can currently be moved and deleted). This is the main + * difference with isCopyable. + * + * TODO(#9098): Revise these criteria. The latter criteria prevents + * shadow blocks from being copied; additionally, there are likely to + * be other circumstances were it is desirable to allow movable / + * copyable copies of a currently-unmovable / -copyable block to be + * made. + * + * @param focused The focused object. + */ +function isCuttable(focused: IFocusableNode): boolean { + if (!(focused instanceof BlockSvg)) return false; + return ( + isICopyable(focused) && + isIDeletable(focused) && + focused.isDeletable() && + isDraggable(focused) && + focused.isMovable() + ); +} + /** * Keyboard shortcut to copy a block on ctrl+c, cmd+c, or alt+c. */ @@ -106,14 +185,17 @@ export function registerCopy() { name: names.COPY, preconditionFn(workspace, scope) { const focused = scope.focusedNode; + if (!(focused instanceof BlockSvg)) return false; + + const targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace + : workspace; return ( - !workspace.isReadOnly() && - !Gesture.inProgress() && - focused != null && - isDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() && + !!focused && + !!targetWorkspace && + !targetWorkspace.isReadOnly() && + !targetWorkspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && isCopyable(focused) ); }, @@ -121,17 +203,27 @@ export function registerCopy() { // Prevent the default copy behavior, which may beep or otherwise indicate // an error due to the lack of a selection. e.preventDefault(); - workspace.hideChaff(); + const focused = scope.focusedNode; if (!focused || !isCopyable(focused)) return false; - copyData = focused.toCopyData(); - copyWorkspace = + let targetWorkspace: WorkspaceSvg | null = focused.workspace instanceof WorkspaceSvg ? focused.workspace : workspace; - copyCoords = isDraggable(focused) - ? focused.getRelativeToSurfaceXY() - : null; + targetWorkspace = targetWorkspace.isFlyout + ? targetWorkspace.targetWorkspace + : targetWorkspace; + if (!targetWorkspace) return false; + + if (!focused.workspace.isFlyout) { + targetWorkspace.hideChaff(); + } + copyData = focused.toCopyData(); + copyWorkspace = targetWorkspace; + copyCoords = + isDraggable(focused) && focused.workspace == targetWorkspace + ? focused.getRelativeToSurfaceXY() + : null; return !!copyData; }, keyCodes: [ctrlC, metaC], @@ -155,15 +247,11 @@ export function registerCut() { preconditionFn(workspace, scope) { const focused = scope.focusedNode; return ( + !!focused && !workspace.isReadOnly() && - !Gesture.inProgress() && - focused != null && - isDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() && - isCopyable(focused) && - !focused.workspace.isFlyout + !workspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && + isCuttable(focused) ); }, callback(workspace, e, shortcut, scope) { @@ -176,9 +264,9 @@ export function registerCut() { focused.checkAndDelete(); return true; } else if ( - isDeletable(focused) && + isIDeletable(focused) && focused.isDeletable() && - isCopyable(focused) + isICopyable(focused) ) { copyData = focused.toCopyData(); copyWorkspace = workspace; @@ -210,10 +298,33 @@ export function registerPaste() { const pasteShortcut: KeyboardShortcut = { name: names.PASTE, preconditionFn(workspace) { - return !workspace.isReadOnly() && !Gesture.inProgress(); + const targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace + : workspace; + return ( + !!copyData && + !!targetWorkspace && + !targetWorkspace.isReadOnly() && + !targetWorkspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() + ); }, - callback() { + callback(workspace: WorkspaceSvg, e: Event) { if (!copyData || !copyWorkspace) return false; + + if (e instanceof PointerEvent) { + // The event that triggers a shortcut would conventionally be a KeyboardEvent. + // However, it may be a PointerEvent if a context menu item was used as a + // wrapper for this callback, in which case the new block(s) should be pasted + // at the mouse coordinates where the menu was opened, and this PointerEvent + // is where the menu was opened. + const mouseCoords = svgMath.screenToWsCoordinates( + copyWorkspace, + new Coordinate(e.clientX, e.clientY), + ); + return !!clipboard.paste(copyData, copyWorkspace, mouseCoords); + } + if (!copyCoords) { // If we don't have location data about the original copyable, let the // paster determine position. @@ -255,7 +366,11 @@ export function registerUndo() { const undoShortcut: KeyboardShortcut = { name: names.UNDO, preconditionFn(workspace) { - return !workspace.isReadOnly() && !Gesture.inProgress(); + return ( + !workspace.isReadOnly() && + !Gesture.inProgress() && + !getFocusManager().ephemeralFocusTaken() + ); }, callback(workspace, e) { // 'z' for undo 'Z' is for redo. @@ -290,7 +405,11 @@ export function registerRedo() { const redoShortcut: KeyboardShortcut = { name: names.REDO, preconditionFn(workspace) { - return !Gesture.inProgress() && !workspace.isReadOnly(); + return ( + !Gesture.inProgress() && + !workspace.isReadOnly() && + !getFocusManager().ephemeralFocusTaken() + ); }, callback(workspace, e) { // 'z' for undo 'Z' is for redo. diff --git a/core/shortcut_registry.ts b/core/shortcut_registry.ts index 8a276c3d51c..f40149db816 100644 --- a/core/shortcut_registry.ts +++ b/core/shortcut_registry.ts @@ -278,7 +278,9 @@ export class ShortcutRegistry { * Undefined if no shortcuts exist. */ getShortcutNamesByKeyCode(keyCode: string): string[] | undefined { - return this.keyMap.get(keyCode) || []; + // Copy the list of shortcuts in case one of them unregisters itself + // in its callback. + return this.keyMap.get(keyCode)?.slice() || []; } /** diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index fc7d1aa03cf..7b0db7b3fcd 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -225,6 +225,8 @@ export class ToolboxCategory */ protected createContainer_(): HTMLDivElement { const container = document.createElement('div'); + // Ensure that the category has a tab index to ensure it receives focus when + // clicked (since clicking isn't managed by the toolbox). container.tabIndex = -1; container.id = this.getId(); const className = this.cssConfig_['container']; diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 44ae358cf53..cd5ed245a04 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -54,6 +54,8 @@ export class ToolboxSeparator extends ToolboxItem { */ protected createDom_(): HTMLDivElement { const container = document.createElement('div'); + // Ensure that the separator has a tab index to ensure it receives focus + // when clicked (since clicking isn't managed by the toolbox). container.tabIndex = -1; container.id = this.getId(); const className = this.cssConfig_['container']; diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 0fbb231dc56..57e849ce264 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -22,7 +22,10 @@ import '../events/events_toolbox_item_select.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import {getFocusManager} from '../focus_manager.js'; -import type {IAutoHideable} from '../interfaces/i_autohideable.js'; +import { + isAutoHideable, + type IAutoHideable, +} from '../interfaces/i_autohideable.js'; import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js'; import {isDeletable} from '../interfaces/i_deletable.js'; import type {IDraggable} from '../interfaces/i_draggable.js'; @@ -169,7 +172,7 @@ export class Toolbox ComponentManager.Capability.DRAG_TARGET, ], }); - getFocusManager().registerTree(this); + getFocusManager().registerTree(this, true); } /** @@ -200,7 +203,6 @@ export class Toolbox */ protected createContainer_(): HTMLDivElement { const toolboxContainer = document.createElement('div'); - toolboxContainer.tabIndex = 0; toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); @@ -1142,7 +1144,16 @@ export class Toolbox } /** See IFocusableTree.onTreeBlur. */ - onTreeBlur(_nextTree: IFocusableTree | null): void {} + onTreeBlur(nextTree: IFocusableTree | null): void { + // If navigating to anything other than the toolbox's flyout then clear the + // selection so that the toolbox's flyout can automatically close. + if (!nextTree || nextTree !== this.flyout?.getWorkspace()) { + this.clearSelection(); + if (this.flyout && isAutoHideable(this.flyout)) { + this.flyout.autoHide(false); + } + } + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index 936983e8f10..d07f7fb502b 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -6,6 +6,7 @@ // Former goog.module ID: Blockly.WidgetDiv +import * as browserEvents from './browser_events.js'; import * as common from './common.js'; import {Field} from './field.js'; import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js'; @@ -66,14 +67,23 @@ export function testOnly_setDiv(newDiv: HTMLDivElement | null) { export function createDom() { const container = common.getParentContainer() || document.body; - if (document.querySelector('.' + containerClassName)) { - containerDiv = document.querySelector('.' + containerClassName); + const existingContainer = document.querySelector('div.' + containerClassName); + if (existingContainer) { + containerDiv = existingContainer as HTMLDivElement; } else { - containerDiv = document.createElement('div') as HTMLDivElement; + containerDiv = document.createElement('div'); containerDiv.className = containerClassName; + containerDiv.tabIndex = -1; } - container.appendChild(containerDiv!); + browserEvents.conditionalBind( + containerDiv, + 'keydown', + null, + common.globalShortcutHandler, + ); + + container.appendChild(containerDiv); } /** diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3e8731afd4b..9eb5ea545b8 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -762,8 +762,6 @@ export class WorkspaceSvg */ this.svgGroup_ = dom.createSvgElement(Svg.G, { 'class': 'blocklyWorkspace', - // Only the top-level workspace should be tabbable. - 'tabindex': injectionDiv ? '0' : '-1', 'id': this.id, }); if (injectionDiv) { @@ -849,7 +847,8 @@ export class WorkspaceSvg isParentWorkspace ? this.getInjectionDiv() : undefined, ); - getFocusManager().registerTree(this); + // Only the top-level and flyout workspaces should be tabbable. + getFocusManager().registerTree(this, !!this.injectionDiv || this.isFlyout); return this.svgGroup_; } @@ -2807,13 +2806,13 @@ export class WorkspaceSvg /** See IFocusableTree.onTreeBlur. */ onTreeBlur(nextTree: IFocusableTree | null): void { // If the flyout loses focus, make sure to close it unless focus is being - // lost to a different element on the page. - if (nextTree && this.isFlyout && this.targetWorkspace) { + // lost to the toolbox or ephemeral focus. + if (this.isFlyout && this.targetWorkspace) { // Only hide the flyout if the flyout's workspace is losing focus and that - // focus isn't returning to the flyout itself or the toolbox. + // focus isn't returning to the flyout itself, the toolbox, or ephemeral. + if (getFocusManager().ephemeralFocusTaken()) return; const flyout = this.targetWorkspace.getFlyout(); const toolbox = this.targetWorkspace.getToolbox(); - if (flyout && nextTree === flyout) return; if (toolbox && nextTree === toolbox) return; if (toolbox) toolbox.clearSelection(); if (flyout && isAutoHideable(flyout)) flyout.autoHide(false); diff --git a/package-lock.json b/package-lock.json index 684f30e0681..f313dcf8b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly", - "version": "12.0.0", + "version": "12.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "12.0.0", + "version": "12.1.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -14,7 +14,7 @@ }, "devDependencies": { "@blockly/block-test": "^6.0.4", - "@blockly/dev-tools": "^8.0.6", + "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", @@ -45,7 +45,7 @@ "http-server": "^14.0.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", - "mocha": "^10.0.0", + "mocha": "^11.3.0", "patch-package": "^8.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0", @@ -101,16 +101,17 @@ } }, "node_modules/@blockly/dev-tools": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-8.0.12.tgz", - "integrity": "sha512-jE0y/Z7ggmM2JS4l0Xf2ic3eecuM+ZDjUZNCcM2k6yy0VDJoxOPN63Cq2soswXQRuKHfzRMHY48rCvoKL3MqPA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.0.tgz", + "integrity": "sha512-c2JJbj5Q9mGdy0iUvE5OBOl1zmSMJrSokORgnmrhxGCiJ6QexPGCsi1QAn6uzpUtGKjhpnEAQ6+jX7ROZe7QQg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@blockly/block-test": "^6.0.11", - "@blockly/theme-dark": "^7.0.10", - "@blockly/theme-deuteranopia": "^6.0.10", - "@blockly/theme-highcontrast": "^6.0.10", - "@blockly/theme-tritanopia": "^6.0.10", + "@blockly/block-test": "^7.0.0", + "@blockly/theme-dark": "^8.0.0", + "@blockly/theme-deuteranopia": "^7.0.0", + "@blockly/theme-highcontrast": "^7.0.0", + "@blockly/theme-tritanopia": "^7.0.0", "chai": "^4.2.0", "dat.gui": "^0.7.7", "lodash.assign": "^4.2.0", @@ -122,7 +123,20 @@ "node": ">=8.0.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.0.tgz", + "integrity": "sha512-Y+Iwg1hHmOaqXveTOiZNXHH+jNBP+LC5L8ZxKKWeO8aB9DZD5G2hgApHfLaxeZzqnCl8zspvGnrrlFy9foEdWw==", + "dev": true, + "license": "Apache 2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" } }, "node_modules/@blockly/dev-tools/node_modules/assertion-error": { @@ -195,39 +209,42 @@ } }, "node_modules/@blockly/theme-dark": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-7.0.10.tgz", - "integrity": "sha512-Wc6n115vt9alxzPkEwYtvBBGoPUV3gaYE00dvSKhqXTNoy1Xioujj9kT9VkGmdMO2mhgnJNczSpvxG8tcd4zLQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.0.tgz", + "integrity": "sha512-Fq8ifjCwbJW305Su7SNBP8jXs4h1hp2EdQ9cMGOCr/racRIYfDRRBqjy0ZRLLqI7BsgZKxKy6Aa+OjgWEKeKfw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-deuteranopia": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-6.0.10.tgz", - "integrity": "sha512-im5nIvf/Z0f1vJ9DK5Euu6URfY8G44xeFsat2b7TySF0BfAUWkGsagK3C6D5NatigPxKZqz3exC9zeXEtprAcg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.0.tgz", + "integrity": "sha512-zKhlnD/AF3MR9+Rlwus3vAPq8gwCZaZ08VEupvz5b98mk36suRlIrQanM8HVLGcozxiEvUNrTNOGO5kj8PeTWA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-highcontrast": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-6.0.10.tgz", - "integrity": "sha512-s1hehl/b50IhebCs20hm2hFWbUTqJ2YSGdR0gnp2NLfNNRWwyZHZk+q4aG3k4L0YBWjNfE3XiRCkDISy83dBIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.0.tgz", + "integrity": "sha512-6Apkw5iUlOq1DoOJgwsfo8Iha2OkxXMSNHqb8ZVVmUhCHjce0XMXgq1Rqty/2l/C2AKB+WWLZEWxOyGWYrQViQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-modern": { @@ -243,15 +260,16 @@ } }, "node_modules/@blockly/theme-tritanopia": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-6.0.10.tgz", - "integrity": "sha512-QNIvUHokGMLnCWUzERRZa6sSkD5RIUynWDI+KNurBH21NeWnSNScQiNu0dS/w5MSkZ/Iqqbi79UZoF49SzEayg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.0.tgz", + "integrity": "sha512-22TFAuY8ilKsQomDC8GXMHsCfdR8l75yPPFl6AOCcok2FJLkiyhjGpAy2cNexA9P2xP/rW7vdsG3wC8ukWihUA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@csstools/color-helpers": { @@ -379,16 +397,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -1222,9 +1244,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.0.tgz", - "integrity": "sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA==", + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.4.tgz", + "integrity": "sha512-9DxbZx+XGMNdjBynIs4BRSz+M3iRDeB7qRcAr6UORFLphCIM2x3DXgOucvADiifcqCE4XePFUKcnaAMyGbrDlQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1244,9 +1266,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -1523,21 +1545,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", - "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", + "integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/type-utils": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/type-utils": "8.32.1", + "@typescript-eslint/utils": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1552,17 +1574,27 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", + "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", - "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz", + "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "engines": { @@ -1578,14 +1610,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", - "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", + "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1" + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1596,16 +1628,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", - "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz", + "integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/utils": "8.31.1", + "@typescript-eslint/typescript-estree": "8.32.1", + "@typescript-eslint/utils": "8.32.1", "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1620,9 +1652,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", - "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", + "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", "dev": true, "license": "MIT", "engines": { @@ -1634,20 +1666,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", - "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", + "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1687,9 +1719,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -1700,16 +1732,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", - "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", + "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.1", + "@typescript-eslint/types": "8.32.1", + "@typescript-eslint/typescript-estree": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1724,13 +1756,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", - "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", + "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/types": "8.32.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1755,15 +1787,15 @@ } }, "node_modules/@wdio/config": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.12.5.tgz", - "integrity": "sha512-T4pOgY7FLj0+SBc58n81JZidCJKfqaSb9Ql9lOd38tmorEwTKjcPAzQQY1Ftzqv49kjBHvXdlupy685VVKNepA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.14.0.tgz", + "integrity": "sha512-mW6VAXfUgd2j+8YJfFWvg8Ba/7g1Brr6/+MFBpp5rTQsw/2bN3PBJsQbWpNl99OCgoS8vgc5Ykps5ZUEeffSVQ==", "dev": true, "license": "MIT", "dependencies": { "@wdio/logger": "9.4.4", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "deepmerge-ts": "^7.0.3", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0" @@ -1905,9 +1937,9 @@ } }, "node_modules/@wdio/protocols": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.12.5.tgz", - "integrity": "sha512-i+yc0EZtZOh5fFuwHxvcnXeTXk2ZjFICRbcAxTNE0F2Jr4uOydvcAOw4EIIRmb9NWUSPf/bGZAA+4SEXmxmjUA==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.14.0.tgz", + "integrity": "sha512-inJR+G8iiFrk8/JPMfxpy6wA7rvMIZFV0T8vDN1Io7sGGj+EXX7ujpDxoCns53qxV4RytnSlgHRcCaASPFcecQ==", "dev": true, "license": "MIT" }, @@ -1925,9 +1957,9 @@ } }, "node_modules/@wdio/types": { - "version": "9.12.3", - "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.12.3.tgz", - "integrity": "sha512-MlnQ3WG1CQAjmUmeKtv3timGR91hSsCwQW9T1kqpu0VaJ/qbw3sWgtArMqRvgWB2H6IGueqQwDQ9qHlP013w9Q==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.14.0.tgz", + "integrity": "sha512-Zqc4sxaQLIXdI1EHItIuVIOn7LvPmDvl9JEANwiJ35ck82Xlj+X55Gd9NtELSwChzKgODD0OBzlLgXyxTr69KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1938,15 +1970,15 @@ } }, "node_modules/@wdio/utils": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.12.5.tgz", - "integrity": "sha512-yddJj7VyA3kGuAuDU63ZdRBK4D1jwSU+52KwlZtOeqDdT/i6KAwRVYNYMwwmsGuM4GpY3q5h944YylBQNkKkjQ==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.14.0.tgz", + "integrity": "sha512-oJapwraSflOe0CmeF3TBocdt983hq9mCutLCfie4QmE+TKRlCsZz4iidG1NRAZPGdKB32nfHtyQlW0Dfxwn6RA==", "dev": true, "license": "MIT", "dependencies": { "@puppeteer/browsers": "^2.2.0", "@wdio/logger": "9.4.4", - "@wdio/types": "9.12.3", + "@wdio/types": "9.14.0", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "edgedriver": "^6.1.1", @@ -1969,9 +2001,9 @@ "dev": true }, "node_modules/@zip.js/zip.js": { - "version": "2.7.60", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz", - "integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==", + "version": "2.7.61", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.61.tgz", + "integrity": "sha512-+tZvY10nkW0pJoU88XFWLBd2O9PJPvEnDhSY/jQHfIroN5W5qGfPgFHKC4lkx0+9Vw/0IAkNHf1XBVInBkM9Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2105,15 +2137,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-gray": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", @@ -2637,9 +2660,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", - "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -5305,9 +5328,9 @@ } }, "node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", "dev": true, "license": "ISC", "dependencies": { @@ -7275,30 +7298,31 @@ } }, "node_modules/mocha": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", - "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz", + "integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^5.1.6", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { @@ -7306,7 +7330,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/brace-expansion": { @@ -7314,35 +7338,93 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mocha/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/mocha/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7350,46 +7432,57 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/mocha/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "has-flag": "^4.0.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "node_modules/mocha/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, + "license": "ISC" + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/mocha/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/monaco-editor": { @@ -7461,6 +7554,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, "funding": [ { @@ -9759,15 +9853,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.1.tgz", - "integrity": "sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA==", + "version": "8.32.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz", + "integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.31.1", - "@typescript-eslint/parser": "8.31.1", - "@typescript-eslint/utils": "8.31.1" + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1", + "@typescript-eslint/utils": "8.32.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9824,10 +9918,11 @@ } }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.17" } @@ -10127,19 +10222,19 @@ } }, "node_modules/webdriver": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.12.5.tgz", - "integrity": "sha512-CQCb1kDh52VtzPOIWc6XOdRz9q07LMAm9XwL+ABLSd0gueJq+GZoUTqHVX1YwVF0EQlFnw0JYJok0hxGH7m7cw==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.14.0.tgz", + "integrity": "sha512-0mVjxafQ5GNdK4l/FVmmmXGUfLHCSBE4Ml2LG23rxgmw53CThAos6h01UgIEINonxIzgKEmwfqJioo3/frbpbQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "@wdio/config": "9.12.5", + "@wdio/config": "9.14.0", "@wdio/logger": "9.4.4", - "@wdio/protocols": "9.12.5", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/protocols": "9.14.0", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "deepmerge-ts": "^7.0.3", "undici": "^6.20.1", "ws": "^8.8.0" @@ -10149,20 +10244,20 @@ } }, "node_modules/webdriverio": { - "version": "9.12.5", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.12.5.tgz", - "integrity": "sha512-ho7gEOdPkpMlZJ5fbCX6+zAllnVdYl8X9RZ4x3tDabf3ByEzReqexaTVou8ayWmNngGjarWlXX3ov1BIdhQTLQ==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.14.0.tgz", + "integrity": "sha512-GP0p6J+yjcCXF9uXW7HjB6IEh33OKmZcLTSg/W2rnVYSWgsUEYPujKSXe5I8q5a99QID7OOKNKVMfs5ANoZ2BA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", - "@wdio/config": "9.12.5", + "@wdio/config": "9.14.0", "@wdio/logger": "9.4.4", - "@wdio/protocols": "9.12.5", + "@wdio/protocols": "9.14.0", "@wdio/repl": "9.4.4", - "@wdio/types": "9.12.3", - "@wdio/utils": "9.12.5", + "@wdio/types": "9.14.0", + "@wdio/utils": "9.14.0", "archiver": "^7.0.1", "aria-query": "^5.3.0", "cheerio": "^1.0.0-rc.12", @@ -10179,7 +10274,7 @@ "rgb2hex": "0.2.5", "serialize-error": "^11.0.3", "urlpattern-polyfill": "^10.0.0", - "webdriver": "9.12.5" + "webdriver": "9.14.0" }, "engines": { "node": ">=18.20.0" diff --git a/package.json b/package.json index 5407f21afd7..eab39b16cde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.0.0", + "version": "12.1.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" @@ -101,7 +101,7 @@ "license": "Apache-2.0", "devDependencies": { "@blockly/block-test": "^6.0.4", - "@blockly/dev-tools": "^8.0.6", + "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", "@hyperjump/json-schema": "^1.5.0", @@ -132,7 +132,7 @@ "http-server": "^14.0.0", "json5": "^2.2.0", "markdown-tables-to-json": "^0.1.7", - "mocha": "^10.0.0", + "mocha": "^11.3.0", "patch-package": "^8.0.0", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.0.0", diff --git a/tests/browser/test/basic_block_test.mjs b/tests/browser/test/basic_block_test.mjs index 326e20ecfd4..52912f0cd86 100644 --- a/tests/browser/test/basic_block_test.mjs +++ b/tests/browser/test/basic_block_test.mjs @@ -31,7 +31,7 @@ suite('Basic block tests', function (done) { test('Drag three blocks into the workspace', async function () { for (let i = 1; i <= 3; i++) { - await dragNthBlockFromFlyout(this.browser, 'Align', 0, 250, 50 * i); + await dragNthBlockFromFlyout(this.browser, 'Align', 0, 50, 50); chai.assert.equal((await getAllBlocks(this.browser)).length, i); } }); diff --git a/tests/browser/test/basic_playground_test.mjs b/tests/browser/test/basic_playground_test.mjs index c0a1f893037..4c54523bd7f 100644 --- a/tests/browser/test/basic_playground_test.mjs +++ b/tests/browser/test/basic_playground_test.mjs @@ -126,15 +126,15 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'logic_boolean', - 110, - 110, + 100, + 0, ); await connect(this.browser, child, 'OUTPUT', parent, 'IF0'); await this.browser.pause(PAUSE_TIME); @@ -152,18 +152,20 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'controls_if', - 110, - 110, + 100, + 0, ); + await this.browser.pause(PAUSE_TIME); await connect(this.browser, child, 'PREVIOUS', parent, 'DO0'); + await this.browser.pause(PAUSE_TIME); await contextMenuSelect(this.browser, parent, 'Disable Block'); chai.assert.isTrue(await getIsDisabled(this.browser, child.id)); @@ -178,16 +180,17 @@ suite('Disabling', function () { this.browser, 'Logic', 'controls_if', - 10, - 10, + 15, + 0, ); const child = await dragBlockTypeFromFlyout( this.browser, 'Logic', 'controls_if', - 110, - 110, + 100, + 0, ); + await connect(this.browser, child, 'PREVIOUS', parent, 'NEXT'); await contextMenuSelect(this.browser, parent, 'Disable Block'); diff --git a/tests/browser/test/delete_blocks_test.mjs b/tests/browser/test/delete_blocks_test.mjs index a5df88705c5..a407ad0600f 100644 --- a/tests/browser/test/delete_blocks_test.mjs +++ b/tests/browser/test/delete_blocks_test.mjs @@ -141,7 +141,7 @@ suite('Delete blocks', function (done) { test('Delete block using backspace key', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( @@ -154,7 +154,7 @@ suite('Delete blocks', function (done) { test('Delete block using delete key', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using delete key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Delete]); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( @@ -176,10 +176,11 @@ suite('Delete blocks', function (done) { ); }); - test('Undo block deletion', async function () { + // TODO(#9029) enable this test once deleting a block doesn't lose focus + test.skip('Undo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); await this.browser.pause(PAUSE_TIME); // Undo @@ -187,8 +188,8 @@ suite('Delete blocks', function (done) { await this.browser.pause(PAUSE_TIME); const after = (await getAllBlocks(this.browser)).length; chai.assert.equal( - before, after, + before, 'Expected there to be the original number of blocks after undoing a delete', ); }); @@ -196,7 +197,7 @@ suite('Delete blocks', function (done) { test('Redo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. - await clickBlock(this.browser, this.firstBlock, {button: 1}); + await clickBlock(this.browser, this.firstBlock.id, {button: 1}); await this.browser.keys([Key.Backspace]); await this.browser.pause(PAUSE_TIME); // Undo diff --git a/tests/browser/test/extensive_test.mjs b/tests/browser/test/extensive_test.mjs index 786be0ade53..bef8bc9345d 100644 --- a/tests/browser/test/extensive_test.mjs +++ b/tests/browser/test/extensive_test.mjs @@ -11,8 +11,8 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { + clickBlock, getAllBlocks, - getBlockElementById, PAUSE_TIME, testFileLocations, testSetup, @@ -33,18 +33,15 @@ suite('This tests loading Large Configuration and Deletion', function (done) { }); test('deleting block results in the correct number of blocks', async function () { - const fourthRepeatDo = await getBlockElementById( - this.browser, - 'E8bF[-r:B~cabGLP#QYd', - ); - await fourthRepeatDo.click({x: -100, y: -40}); + await clickBlock(this.browser, 'E8bF[-r:B~cabGLP#QYd', {button: 1}); await this.browser.keys([Key.Delete]); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); chai.assert.equal(allBlocks.length, 10); }); - test('undoing delete block results in the correct number of blocks', async function () { + // TODO(#8793) Re-enable test after deleting a block updates focus correctly. + test.skip('undoing delete block results in the correct number of blocks', async function () { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); diff --git a/tests/browser/test/mutator_test.mjs b/tests/browser/test/mutator_test.mjs index 6d077b9fd92..b12ae5698c9 100644 --- a/tests/browser/test/mutator_test.mjs +++ b/tests/browser/test/mutator_test.mjs @@ -34,16 +34,15 @@ async function testMutator(browser, delta) { browser, 'Logic', 'controls_if', - delta * 50, + delta * 150, 50, ); await openMutatorForBlock(browser, mutatorBlock); - await browser.pause(PAUSE_TIME); await dragBlockFromMutatorFlyout( browser, mutatorBlock, 'controls_if_elseif', - delta * 50, + delta * 150, 50, ); await browser.pause(PAUSE_TIME); @@ -67,8 +66,8 @@ async function testMutator(browser, delta) { 'g:nth-child(2) > svg:nth-child(1) > g > g.blocklyBlockCanvas > ' + 'g.blocklyDraggable', ); - // For some reason this needs a lot more time. - await browser.pause(2000); + + await browser.pause(PAUSE_TIME); await connect( browser, await getBlockElementById(browser, elseIfQuarkId), diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 9b48a3638ae..04a192a46a7 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -165,28 +165,35 @@ export async function getBlockElementById(browser, id) { * causes problems if it has holes (e.g. statement inputs). Instead, this tries * to get the first text field on the block. It falls back on the block's SVG root. * @param browser The active WebdriverIO Browser object. - * @param block The block to click, as an interactable element. + * @param blockId The id of the block to click, as an interactable element. * @param clickOptions The options to pass to webdriverio's element.click function. * @return A Promise that resolves when the actions are completed. */ -export async function clickBlock(browser, block, clickOptions) { +export async function clickBlock(browser, blockId, clickOptions) { const findableId = 'clickTargetElement'; // In the browser context, find the element that we want and give it a findable ID. await browser.execute( (blockId, newElemId) => { const block = Blockly.getMainWorkspace().getBlockById(blockId); - for (const input of block.inputList) { - for (const field of input.fieldRow) { - if (field instanceof Blockly.FieldLabel) { - field.getSvgRoot().id = newElemId; - return; + // Ensure the block we want to click is within the viewport. + Blockly.getMainWorkspace().scrollBoundsIntoView( + block.getBoundingRectangleWithoutChildren(), + 10, + ); + if (!block.isCollapsed()) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + if (field instanceof Blockly.FieldLabel) { + field.getSvgRoot().id = newElemId; + return; + } } } } // No label field found. Fall back to the block's SVG root. - block.getSvgRoot().id = findableId; + block.getSvgRoot().id = newElemId; }, - block.id, + blockId, findableId, ); @@ -477,8 +484,8 @@ export async function dragBlockTypeFromFlyout( } /** - * Drags the specified block type from the mutator flyout of the given block and - * returns the root element of the block. + * Drags the specified block type from the mutator flyout of the given block + * and returns the root element of the block. * * @param browser The active WebdriverIO Browser object. * @param mutatorBlock The block with the mutator attached that we want to drag @@ -512,7 +519,18 @@ export async function dragBlockFromMutatorFlyout( ); const flyoutBlock = await getBlockElementById(browser, id); await flyoutBlock.dragAndDrop({x: x, y: y}); - return await getSelectedBlockElement(browser); + + const draggedBlockId = await browser.execute( + (mutatorBlockId, blockType) => { + return Blockly.getMainWorkspace() + .getBlockById(mutatorBlockId) + .mutator.getWorkspace() + .getBlocksByType(blockType)[0].id; + }, + mutatorBlock.id, + type, + ); + return await getBlockElementById(browser, draggedBlockId); } /** @@ -526,8 +544,9 @@ export async function dragBlockFromMutatorFlyout( * @return A Promise that resolves when the actions are completed. */ export async function contextMenuSelect(browser, block, itemText) { - await clickBlock(browser, block, {button: 2}); + await clickBlock(browser, block.id, {button: 2}); + await browser.pause(PAUSE_TIME); const item = await browser.$(`div=${itemText}`); await item.waitForExist(); await item.click(); diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index aa4f5618495..1d283f331a6 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -246,7 +246,7 @@ suite('Cursor', function () { }); test('getLastNode', function () { const node = this.cursor.getLastNode(); - assert.equal(node, this.blockA); + assert.equal(node, this.blockA.inputList[0].connection); }); }); suite('one c-hat block', function () { @@ -340,7 +340,7 @@ suite('Cursor', function () { test('getLastNode', function () { const node = this.cursor.getLastNode(); const blockB = this.workspace.getBlockById('B'); - assert.equal(node, blockB); + assert.equal(node, blockB.inputList[0].connection); }); }); diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index 32109bfcadd..fc792fbaf24 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {Rect} from '../../build/src/core/utils/rect.js'; +import * as style from '../../build/src/core/utils/style.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -11,9 +13,32 @@ import { } from './test_helpers/setup_teardown.js'; suite('DropDownDiv', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv'); + this.setUpBlockWithField = function () { + const blockJson = { + 'type': 'text', + 'id': 'block_id', + 'x': 10, + 'y': 20, + 'fields': { + 'TEXT': '', + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + return this.workspace.getBlockById('block_id'); + }; + // The workspace needs to be visible for focus-specific tests. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + sharedTestTeardown.call(this); + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + }); + suite('Positioning', function () { setup(function () { - sharedTestSetup.call(this); this.boundsStub = sinon .stub(Blockly.DropDownDiv.TEST_ONLY, 'getBoundsInfo') .returns({ @@ -41,9 +66,6 @@ suite('DropDownDiv', function () { return 0; }); }); - teardown(function () { - sharedTestTeardown.call(this); - }); test('Below, in Bounds', function () { const metrics = Blockly.DropDownDiv.TEST_ONLY.getPositionMetrics( 50, @@ -113,4 +135,324 @@ suite('DropDownDiv', function () { assert.isNotOk(metrics.arrowAtTop); }); }); + + suite('Keyboard Shortcuts', function () { + setup(function () { + this.boundsStub = sinon + .stub(Blockly.DropDownDiv.TEST_ONLY, 'getBoundsInfo') + .returns({ + left: 0, + right: 100, + top: 0, + bottom: 100, + width: 100, + height: 100, + }); + this.workspace = Blockly.inject('blocklyDiv', {}); + }); + teardown(function () { + this.boundsStub.restore(); + }); + test('Escape dismisses DropDownDiv', function () { + let hidden = false; + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { + hidden = true; + }); + assert.isFalse(hidden); + Blockly.DropDownDiv.getContentDiv().dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, // example values. + }), + ); + assert.isTrue(hidden); + }); + }); + + suite('show()', function () { + test('without bounds set throws error', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + + const errorMsgRegex = /Cannot read properties of null.+?/; + assert.throws( + () => Blockly.DropDownDiv.show(field, false, 50, 60, 70, 80, false), + errorMsgRegex, + ); + }); + + test('with bounds set positions and shows div near specified location', function () { + Blockly.DropDownDiv.setBoundsElement(document.body); + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + + Blockly.DropDownDiv.show(field, false, 50, 60, 70, 80, false); + + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, '45px'); + assert.strictEqual(dropDownDivElem.style.top, '60px'); + }); + }); + + suite('showPositionedByField()', function () { + test('shows div near field', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const fieldBounds = field.getScaledBBox(); + + Blockly.DropDownDiv.showPositionedByField(field); + + // The div should show below the field and centered horizontally. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + const divWidth = style.getSize(dropDownDivElem).width; + const expectedLeft = Math.floor( + fieldBounds.left + fieldBounds.getWidth() / 2 - divWidth / 2, + ); + const expectedTop = Math.floor(fieldBounds.bottom); // Should show beneath. + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, `${expectedLeft}px`); + assert.strictEqual(dropDownDivElem.style.top, `${expectedTop}px`); + }); + + test('with hide callback does not call callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + + Blockly.DropDownDiv.showPositionedByField(field, onHideCallback); + + // Simply showing the div should never call the hide callback. + assert.strictEqual(onHideCallback.callCount, 0); + }); + + test('without managed ephemeral focus does not change focused node', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByField(field, null, null, false); + + // Since managing ephemeral focus is disabled the current focused node shouldn't be changed. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with managed ephemeral focus focuses drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByField(field, null, null, true); + + // Managing ephemeral focus won't change getFocusedNode() but will change the actual element + // with DOM focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, dropDownDivElem); + }); + }); + + suite('showPositionedByBlock()', function () { + test('shows div near block', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + // Note that the offset must be computed before showing the div since otherwise it can move + // slightly after the div is shown. + const blockOffset = style.getPageOffset(block.getSvgRoot()); + + Blockly.DropDownDiv.showPositionedByBlock(field, block); + + // The div should show below the block and centered horizontally. + const blockLocalBounds = block.getBoundingRectangle(); + const blockBounds = Rect.createFromPoint( + blockOffset, + blockLocalBounds.getWidth(), + blockLocalBounds.getHeight(), + ); + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + const divWidth = style.getSize(dropDownDivElem).width; + const expectedLeft = Math.floor( + blockBounds.left + blockBounds.getWidth() / 2 - divWidth / 2, + ); + const expectedTop = Math.floor(blockBounds.bottom); // Should show beneath. + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + assert.strictEqual(dropDownDivElem.style.left, `${expectedLeft}px`); + assert.strictEqual(dropDownDivElem.style.top, `${expectedTop}px`); + }); + + test('with hide callback does not call callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + + Blockly.DropDownDiv.showPositionedByBlock(field, block, onHideCallback); + + // Simply showing the div should never call the hide callback. + assert.strictEqual(onHideCallback.callCount, 0); + }); + + test('without managed ephemeral focus does not change focused node', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + false, + ); + + // Since managing ephemeral focus is disabled the current focused node shouldn't be changed. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with managed ephemeral focus focuses drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + + Blockly.DropDownDiv.showPositionedByBlock(field, block, null, null, true); + + // Managing ephemeral focus won't change getFocusedNode() but will change the actual element + // with DOM focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, dropDownDivElem); + }); + }); + + suite('hideWithoutAnimation()', function () { + test('when not showing drop-down div keeps opacity at 0', function () { + Blockly.DropDownDiv.hideWithoutAnimation(); + + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + suite('for div positioned by field', function () { + test('hides div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.DropDownDiv.showPositionedByField(field); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Technically this will trigger a CSS animation, but the property is still set to 0. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + test('hide callback calls callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + Blockly.DropDownDiv.showPositionedByField(field, onHideCallback); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should trigger the hide callback. + assert.strictEqual(onHideCallback.callCount, 1); + }); + + test('without ephemeral focus does not change focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, false); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div shouldn't change what would have already been focused. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + }); + + suite('for div positioned by block', function () { + test('hides div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.DropDownDiv.showPositionedByBlock(field, block); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Technically this will trigger a CSS animation, but the property is still set to 0. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); + + test('hide callback calls callback', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + const onHideCallback = sinon.stub(); + Blockly.DropDownDiv.showPositionedByBlock(field, block, onHideCallback); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should trigger the hide callback. + assert.strictEqual(onHideCallback.callCount, 1); + }); + + test('without ephemeral focus does not change focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + false, + ); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div shouldn't change what would have already been focused. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + + test('with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + ); + + Blockly.DropDownDiv.hideWithoutAnimation(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); + }); + }); }); diff --git a/tests/mocha/field_image_test.js b/tests/mocha/field_image_test.js index 89dd5fcc91b..a02b3f6b64a 100644 --- a/tests/mocha/field_image_test.js +++ b/tests/mocha/field_image_test.js @@ -20,6 +20,7 @@ import { suite('Image Fields', function () { setup(function () { sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv'); }); teardown(function () { sharedTestTeardown.call(this); @@ -237,5 +238,114 @@ suite('Image Fields', function () { assert.isTrue(field.getFlipRtl()); }); }); + suite('isClickable', function () { + setup(function () { + this.onClick = function () { + console.log('on click'); + }; + this.setUpBlockWithFieldImages = function () { + const blockJson = { + 'type': 'text', + 'id': 'block_id', + 'x': 0, + 'y': 0, + 'fields': { + 'TEXT': '', + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + return this.workspace.getBlockById('block_id'); + }; + this.extractFieldImage = function (block) { + const fields = Array.from(block.getFields()); + // Sanity check (as a precondition). + assert.strictEqual(fields.length, 3); + const imageField = fields[0]; + // Sanity check (as a precondition). + assert.isTrue(imageField instanceof Blockly.FieldImage); + return imageField; + }; + }); + + test('Unattached field without click handler returns false', function () { + const field = new Blockly.FieldImage('src', 10, 10, null); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('Unattached field with click handler returns false', function () { + const field = new Blockly.FieldImage('src', 10, 10, this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached but disabled field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setEnabled(false); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached but disabled field with click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setEnabled(false); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, but not editable field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + block.setEditable(false); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, but not editable field with click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + block.setEditable(false); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, editable field without click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + test('For attached, enabled, editable field with click handler returns true', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setOnClickHandler(this.onClick); + + const isClickable = field.isClickable(); + + assert.isTrue(isClickable); + }); + test('For attached, enabled, editable field with removed click handler returns false', function () { + const block = this.setUpBlockWithFieldImages(); + const field = this.extractFieldImage(block); + field.setOnClickHandler(this.onClick); + field.setOnClickHandler(null); + + const isClickable = field.isClickable(); + + assert.isFalse(isClickable); + }); + }); }); }); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index b1cfb029a87..3a1fc98a7e5 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -80,81 +80,86 @@ suite('FocusManager', function () { const ACTIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME}`; const PASSIVE_FOCUS_NODE_CSS_SELECTOR = `.${FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME}`; - const createFocusableTree = function (rootElementId, nestedTrees) { - return new FocusableTreeImpl( - document.getElementById(rootElementId), - nestedTrees || [], - ); - }; - const createFocusableNode = function (tree, elementId) { - return tree.addNode(document.getElementById(elementId)); - }; - setup(function () { sharedTestSetup.call(this); - this.focusManager = getFocusManager(); - this.testFocusableTree1 = createFocusableTree('testFocusableTree1'); - this.testFocusableTree1Node1 = createFocusableNode( + this.allFocusableTrees = []; + this.allFocusableNodes = []; + this.createFocusableTree = function (rootElementId, nestedTrees) { + const tree = new FocusableTreeImpl( + document.getElementById(rootElementId), + nestedTrees || [], + ); + this.allFocusableTrees.push(tree); + return tree; + }; + this.createFocusableNode = function (tree, elementId) { + const node = tree.addNode(document.getElementById(elementId)); + this.allFocusableNodes.push(node); + return node; + }; + + this.testFocusableTree1 = this.createFocusableTree('testFocusableTree1'); + this.testFocusableTree1Node1 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node1', ); - this.testFocusableTree1Node1Child1 = createFocusableNode( + this.testFocusableTree1Node1Child1 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node1.child1', ); - this.testFocusableTree1Node2 = createFocusableNode( + this.testFocusableTree1Node2 = this.createFocusableNode( this.testFocusableTree1, 'testFocusableTree1.node2', ); - this.testFocusableNestedTree4 = createFocusableTree( + this.testFocusableNestedTree4 = this.createFocusableTree( 'testFocusableNestedTree4', ); - this.testFocusableNestedTree4Node1 = createFocusableNode( + this.testFocusableNestedTree4Node1 = this.createFocusableNode( this.testFocusableNestedTree4, 'testFocusableNestedTree4.node1', ); - this.testFocusableNestedTree5 = createFocusableTree( + this.testFocusableNestedTree5 = this.createFocusableTree( 'testFocusableNestedTree5', ); - this.testFocusableNestedTree5Node1 = createFocusableNode( + this.testFocusableNestedTree5Node1 = this.createFocusableNode( this.testFocusableNestedTree5, 'testFocusableNestedTree5.node1', ); - this.testFocusableTree2 = createFocusableTree('testFocusableTree2', [ + this.testFocusableTree2 = this.createFocusableTree('testFocusableTree2', [ this.testFocusableNestedTree4, this.testFocusableNestedTree5, ]); - this.testFocusableTree2Node1 = createFocusableNode( + this.testFocusableTree2Node1 = this.createFocusableNode( this.testFocusableTree2, 'testFocusableTree2.node1', ); - this.testFocusableGroup1 = createFocusableTree('testFocusableGroup1'); - this.testFocusableGroup1Node1 = createFocusableNode( + this.testFocusableGroup1 = this.createFocusableTree('testFocusableGroup1'); + this.testFocusableGroup1Node1 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node1', ); - this.testFocusableGroup1Node1Child1 = createFocusableNode( + this.testFocusableGroup1Node1Child1 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node1.child1', ); - this.testFocusableGroup1Node2 = createFocusableNode( + this.testFocusableGroup1Node2 = this.createFocusableNode( this.testFocusableGroup1, 'testFocusableGroup1.node2', ); - this.testFocusableNestedGroup4 = createFocusableTree( + this.testFocusableNestedGroup4 = this.createFocusableTree( 'testFocusableNestedGroup4', ); - this.testFocusableNestedGroup4Node1 = createFocusableNode( + this.testFocusableNestedGroup4Node1 = this.createFocusableNode( this.testFocusableNestedGroup4, 'testFocusableNestedGroup4.node1', ); - this.testFocusableGroup2 = createFocusableTree('testFocusableGroup2', [ + this.testFocusableGroup2 = this.createFocusableTree('testFocusableGroup2', [ this.testFocusableNestedGroup4, ]); - this.testFocusableGroup2Node1 = createFocusableNode( + this.testFocusableGroup2Node1 = this.createFocusableNode( this.testFocusableGroup2, 'testFocusableGroup2.node1', ); @@ -177,6 +182,19 @@ suite('FocusManager', function () { elem.classList.remove(FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME); } + // Ensure any set tab indexes are properly reset between tests. + for (const tree of this.allFocusableTrees) { + tree + .getRootFocusableNode() + .getFocusableElement() + .removeAttribute('tabindex'); + } + for (const node of this.allFocusableNodes) { + node.getFocusableElement().removeAttribute('tabindex'); + } + this.allFocusableTrees = []; + this.allFocusableNodes = []; + // Reset the current active element. document.body.focus(); }); @@ -230,6 +248,44 @@ suite('FocusManager', function () { // The second register should not fail since the tree was previously unregistered. }); + + test('for unmanaged tree does not overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('for unmanaged tree with custom tab index does not overwrite tab index', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.registerTree(this.testFocusableTree1, false); + + // The custom tab index shouldn't be overwritten for an unmanaged tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('for managed tree overwrites root tab index to be tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('for managed tree with custom tab index overwrites root tab index to be tab navigable', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.registerTree(this.testFocusableTree1, true); + + // A custom tab index should be overwritten for a managed tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); }); suite('unregisterTree()', function () { @@ -259,6 +315,41 @@ suite('FocusManager', function () { errorMsgRegex, ); }); + + test('for unmanaged tree with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering an unmanaged tree shouldn't change its tab index. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('for managed tree removes tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering a managed tree should remove its tab index. + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('for managed tree with custom tab index removes tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = -1; + + this.focusManager.unregisterTree(this.testFocusableTree1); + + // Unregistering a managed tree should remove its tab index. + assert.isNull(rootElem.getAttribute('tabindex')); + }); }); suite('isRegistered()', function () { @@ -330,6 +421,17 @@ suite('FocusManager', function () { assert.isNull(focusedNode); }); + + test('after focusing unfocusable node returns null', function () { + this.testFocusableTree1Node1.canBeFocused = () => false; + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const focusedNode = this.focusManager.getFocusedNode(); + + // Unfocusable nodes should not be focused. + assert.isNull(focusedNode); + }); }); suite('focusTree()', function () { @@ -353,6 +455,15 @@ suite('FocusManager', function () { }); }); + test('unfocused node does not have a tab index by default', function () { + const elem = this.testFocusableTree1Node1.getFocusableElement(); + + // This is slightly testing the test setup, but it acts as a precondition sanity test for the + // other tab index tests below. Important: 'getAttribute' is used here since direct access to + // 'tabIndex' can default the value returned even when the tab index isn't set. + assert.isNull(elem.getAttribute('tabindex')); + }); + suite('focusNode()', function () { test('for not registered node throws', function () { const errorMsgRegex = /Attempted to focus unregistered node.+?/; @@ -419,6 +530,295 @@ suite('FocusManager', function () { const currentNode = this.focusManager.getFocusedNode(); assert.strictEqual(currentNode, this.testFocusableTree1Node2); }); + + test('restores focus when element quietly loses focus', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node1); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('restores focus when element and new node focused', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + // Remove the FocusManager's listeners to simulate not receiving a focus + // event when focus is lost. This can happen in Firefox and Safari when an + // element is removed and then re-added to the DOM. This is a contrived + // setup to achieve the same outcome on all browsers. For context, see: + // https://github.com/google/blockly-keyboard-experimentation/issues/87. + for (const registeredListener of this.globalDocumentEventListeners) { + const eventType = registeredListener.type; + const eventListener = registeredListener.listener; + document.removeEventListener(eventType, eventListener); + } + document.body.focus(); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + const currentNode = this.focusManager.getFocusedNode(); + const currentElem = currentNode?.getFocusableElement(); + assert.strictEqual(currentNode, this.testFocusableTree1Node2); + assert.strictEqual(document.activeElement, currentElem); + }); + + test('for unfocused node calls onNodeFocus once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1); + }); + + test('for previously focused node calls onNodeBlur once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + assert.strictEqual(this.testFocusableTree1Node1.onNodeBlur.callCount, 1); + }); + + test('for unfocused tree calls onTreeFocus once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeFocus.callCount, 1); + }); + + test('for previously focused tree calls onTreeBlur once', function () { + sinon.spy(this.testFocusableTree1, 'onTreeBlur'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + assert.strictEqual(this.testFocusableTree1.onTreeBlur.callCount, 1); + }); + + test('for same node twice calls onNodeFocus once', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Call focus for the same node a second time. + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Despite two calls to focus the node should only focus once. + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 1); + }); + + test('for unfocusable node does not call onNodeFocus', function () { + sinon.spy(this.testFocusableTree1Node1, 'onNodeFocus'); + this.testFocusableTree1Node1.canBeFocused = () => false; + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Unfocusable nodes should not be focused, nor have their callbacks called. + assert.strictEqual(this.testFocusableTree1Node1.onNodeFocus.callCount, 0); + }); + + test('for unfocused node overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an element should overwrite its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for previously focused node keeps new tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + // The previously focused element should retain its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for node with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1); + const elem = this.testFocusableTree1Node1.getFocusableElement(); + elem.tabIndex = 0; + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // If the node already has a tab index set then it should retain that index. + assert.strictEqual(elem.getAttribute('tabindex'), '0'); + }); + + suite('for unmanaged tree', function () { + test('focused root overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(rootNode); + + // Focusing an unmanaged tree's root should overwrite its tab index. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused root with custom tab index does not change tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(rootNode); + + // If the node already has a tab index set then it should retain that index. + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('focused node in a tree after unmanaged was focused should keep previous root unchanged', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't change the root of the previous tree if it's unmanaged. + const rootElem = rootNode.getFocusableElement(); + assert.isNull(rootElem.getAttribute('tabindex')); + }); + + test('focused node in a tree after unmanaged was root focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, false); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // The previous tree's root should be kept unchanged (since it was managed). + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + }); + + suite('for managed tree', function () { + test('for unfocused node in managed tree overwrites tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing an element should overwrite its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('for previously focused node in managed tree keeps new tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree1Node2); + + // The previously focused element should retain its tab index. + const elem = this.testFocusableTree1Node1.getFocusableElement(); + assert.strictEqual(elem.getAttribute('tabindex'), '-1'); + }); + + test('focused root makes root non-tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(rootNode); + + // Focusing the root in a managed tree should make it non-tab navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused root with custom tab index should overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(rootNode); + + // Custom tab indexes are overwritten for the root in a managed tree. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node tree root makes root non-tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Focusing a node of a managed tree should make the root non-tab navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node root with custom tab index should overwrite tab index', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + rootElem.tabIndex = 0; + + this.focusManager.focusNode(this.testFocusableTree1Node1); + + // Custom tab indexes are overwritten for the root in a managed tree even when a tree's node + // is focused. + assert.strictEqual(rootElem.getAttribute('tabindex'), '-1'); + }); + + test('focused node in a tree after managed was focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't after a managed tree should make the managed tree tab + // navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + + test('focused node in a tree after managed was root focused should make previous root tab navigable', function () { + this.focusManager.registerTree(this.testFocusableTree1, true); + this.focusManager.registerTree(this.testFocusableTree2, false); + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + this.focusManager.focusTree(this.testFocusableTree1); + + this.focusManager.focusNode(this.testFocusableTree2Node1); + + // Focusing a different tree shouldn't after a managed tree should make the managed tree tab + // navigable. + const rootElem = rootNode.getFocusableElement(); + assert.strictEqual(rootElem.getAttribute('tabindex'), '0'); + }); + }); }); suite('getFocusManager()', function () { @@ -865,8 +1265,8 @@ suite('FocusManager', function () { nodeElem.textContent = 'Focusable node'; rootElem.appendChild(nodeElem); document.body.appendChild(rootElem); - const root = createFocusableTree('focusRoot'); - const node = createFocusableNode(root, 'focusNode'); + const root = this.createFocusableTree('focusRoot'); + const node = this.createFocusableNode(root, 'focusNode'); this.focusManager.registerTree(root); this.focusManager.focusNode(node); @@ -1339,6 +1739,7 @@ suite('FocusManager', function () { suite('getFocusedTree()', function () { test('registered root focus()ed no prev focus returns tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1350,6 +1751,7 @@ suite('FocusManager', function () { test("registered node focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1361,6 +1763,8 @@ suite('FocusManager', function () { test("registered subnode focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableTree1.node1.child1').focus(); @@ -1372,6 +1776,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns same tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1385,6 +1791,8 @@ suite('FocusManager', function () { test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1398,6 +1806,8 @@ suite('FocusManager', function () { test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -1410,6 +1820,9 @@ suite('FocusManager', function () { test("non-registered node subelement focus()ed returns node's tree", function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -1423,12 +1836,18 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedTree()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedTree()); @@ -1436,6 +1855,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); @@ -1445,6 +1868,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original tree', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -1457,6 +1881,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1466,6 +1891,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1476,6 +1902,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -1488,6 +1916,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1503,6 +1933,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1518,6 +1951,7 @@ suite('FocusManager', function () { test('nested tree focusTree()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -1530,6 +1964,7 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1542,6 +1977,8 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed after parent focused returns nested tree', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1555,6 +1992,7 @@ suite('FocusManager', function () { suite('getFocusedNode()', function () { test('registered root focus()ed no prev focus returns root node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1566,6 +2004,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus returns node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1577,6 +2016,8 @@ suite('FocusManager', function () { test('registered subnode focus()ed no prev focus returns subnode', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableTree1.node1.child1').focus(); @@ -1588,6 +2029,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1601,6 +2044,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1614,6 +2059,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -1626,6 +2073,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed returns nearest node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -1639,12 +2089,18 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedNode()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedNode()); @@ -1652,6 +2108,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnregisteredFocusableTree3.node1').focus(); @@ -1661,6 +2121,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original node', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -1673,6 +2134,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1682,6 +2144,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1692,6 +2155,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -1704,6 +2169,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new node', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1719,6 +2186,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -1734,6 +2204,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev focus returns nested root', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -1746,6 +2217,7 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus returns focused node', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1758,6 +2230,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused returns focused node', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -1778,9 +2252,10 @@ suite('FocusManager', function () { nodeElem.textContent = 'Focusable node'; rootElem.appendChild(nodeElem); document.body.appendChild(rootElem); - const root = createFocusableTree('focusRoot'); - const node = createFocusableNode(root, 'focusNode'); + const root = this.createFocusableTree('focusRoot'); + const node = this.createFocusableNode(root, 'focusNode'); this.focusManager.registerTree(root); + document.getElementById('focusNode').tabIndex = -1; document.getElementById('focusNode').focus(); node.getFocusableElement().remove(); @@ -1788,10 +2263,44 @@ suite('FocusManager', function () { assert.notStrictEqual(this.focusManager.getFocusedNode(), node); rootElem.remove(); // Cleanup. }); + + test('after focus() after trying to focusNode() an unfocusable node updates returns focus()ed node', function () { + this.testFocusableTree1Node1.canBeFocused = () => false; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableTree1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + document.getElementById('testFocusableTree1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() an unfocusable + // node. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); + + test('after focus() after trying to focusNode() the same node twice returns focus()ed node', function () { + document.getElementById('testFocusableTree1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableTree1); + // Intentionally try to focus the same node twice. + this.focusManager.focusNode(this.testFocusableTree1Node1); + this.focusManager.focusNode(this.testFocusableTree1Node1); + + document.getElementById('testFocusableTree1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() the same node + // twice. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree1Node2, + ); + }); }); suite('CSS classes', function () { test('registered root focus()ed no prev focus returns root elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); @@ -1810,6 +2319,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); @@ -1826,6 +2336,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1843,6 +2355,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree1.node2').focus(); @@ -1861,6 +2375,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1879,6 +2395,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -1897,6 +2415,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2').focus(); @@ -1916,6 +2436,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed nearest node has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById( + 'testFocusableTree1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableTree1.node2.unregisteredChild1') @@ -1934,10 +2457,11 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const rootElem = document.getElementById( 'testUnregisteredFocusableTree3', ); @@ -1952,10 +2476,13 @@ suite('FocusManager', function () { }); test('non-registered tree node focus()ed has no focus', function () { + document.getElementById( + 'testUnregisteredFocusableTree3.node1', + ).tabIndex = -1; + document.getElementById('testUnregisteredFocusableTree3.node1').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const nodeElem = document.getElementById( 'testUnregisteredFocusableTree3.node1', ); @@ -1971,6 +2498,7 @@ suite('FocusManager', function () { test('unfocsable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -2001,6 +2529,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2021,6 +2550,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableTree1); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2040,6 +2570,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableTree1.node1').focus(); @@ -2072,6 +2604,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2104,6 +2638,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableTree1); @@ -2136,6 +2673,9 @@ suite('FocusManager', function () { test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node2').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2158,6 +2698,9 @@ suite('FocusManager', function () { test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1').tabIndex = -1; document.getElementById('testFocusableTree1.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2191,6 +2734,9 @@ suite('FocusManager', function () { test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { this.focusManager.registerTree(this.testFocusableTree1); this.focusManager.registerTree(this.testFocusableTree2); + document.getElementById('testFocusableTree1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableTree1.node1').tabIndex = -1; document.getElementById('testFocusableTree1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -2221,6 +2767,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev root has active focus', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4').tabIndex = -1; document.getElementById('testFocusableNestedTree4').focus(); @@ -2240,6 +2787,7 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus node has active focus', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -2258,6 +2806,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused prev has passive node has active', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -3170,6 +3720,7 @@ suite('FocusManager', function () { suite('getFocusedTree()', function () { test('registered root focus()ed no prev focus returns tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3181,6 +3732,7 @@ suite('FocusManager', function () { test("registered node focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3192,6 +3744,8 @@ suite('FocusManager', function () { test("registered subnode focus()ed no prev focus returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableGroup1.node1.child1').focus(); @@ -3203,6 +3757,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns same tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3216,6 +3772,8 @@ suite('FocusManager', function () { test("registered node focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3229,6 +3787,8 @@ suite('FocusManager', function () { test("registered tree root focus()ed after prev node focus diff tree returns new node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3241,6 +3801,9 @@ suite('FocusManager', function () { test("non-registered node subelement focus()ed returns node's tree", function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3254,12 +3817,19 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedTree()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); @@ -3269,6 +3839,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document @@ -3283,6 +3857,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3292,6 +3867,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3302,6 +3878,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3314,6 +3892,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3329,6 +3909,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3344,6 +3927,7 @@ suite('FocusManager', function () { test('nested tree focusTree()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -3356,6 +3940,8 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed with no prev focus returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3368,6 +3954,9 @@ suite('FocusManager', function () { test('nested tree node focusNode()ed after parent focused returns nested tree', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3381,6 +3970,7 @@ suite('FocusManager', function () { suite('getFocusedNode()', function () { test('registered root focus()ed no prev focus returns root node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3392,6 +3982,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus returns node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3403,6 +3994,8 @@ suite('FocusManager', function () { test('registered subnode focus()ed no prev focus returns subnode', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1.child1').tabIndex = + -1; document.getElementById('testFocusableGroup1.node1.child1').focus(); @@ -3414,6 +4007,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3427,6 +4022,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3440,6 +4037,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree returns new root', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3452,6 +4051,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed returns nearest node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3465,12 +4067,19 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed returns null', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedNode()); }); test('non-registered tree node focus()ed returns null', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); @@ -3480,6 +4089,10 @@ suite('FocusManager', function () { test('non-registered tree node focus()ed after registered node focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document @@ -3491,6 +4104,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused returns original node', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -3503,6 +4117,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3512,6 +4127,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3522,6 +4138,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior focused returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3534,6 +4152,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently focused returns new node', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3549,6 +4169,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering returns null', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3564,6 +4187,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev focus returns nested root', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -3576,6 +4200,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus returns focused node', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3588,6 +4214,9 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused returns focused node', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -3597,10 +4226,44 @@ suite('FocusManager', function () { this.testFocusableNestedGroup4Node1, ); }); + + test('after focus() after trying to focusNode() an unfocusable node updates returns focus()ed node', function () { + this.testFocusableGroup1Node1.canBeFocused = () => false; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableGroup1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + document.getElementById('testFocusableGroup1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() an unfocusable + // node. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); + + test('after focus() after trying to focusNode() the same node twice returns focus()ed node', function () { + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; + this.focusManager.registerTree(this.testFocusableGroup1); + // Intentionally try to focus the same node twice. + this.focusManager.focusNode(this.testFocusableGroup1Node1); + this.focusManager.focusNode(this.testFocusableGroup1Node1); + + document.getElementById('testFocusableGroup1.node2').focus(); + + // focus()ing a new node should overwrite a failed attempt to focusNode() the same node + // twice. This verifies that DOM focus syncing is properly reenabled by FocusManager. + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableGroup1Node2, + ); + }); }); suite('CSS classes', function () { test('registered root focus()ed no prev focus returns root elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); @@ -3619,6 +4282,7 @@ suite('FocusManager', function () { test('registered node focus()ed no prev focus node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); @@ -3635,6 +4299,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree old node elem has no focus property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3653,6 +4319,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus same tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup1.node2').focus(); @@ -3671,6 +4339,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree old node elem has passive property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3690,6 +4360,8 @@ suite('FocusManager', function () { test('registered node focus()ed after prev node focus diff tree new node elem has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3708,6 +4380,8 @@ suite('FocusManager', function () { test('registered tree root focus()ed after prev node focus diff tree new root has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2').focus(); @@ -3727,6 +4401,9 @@ suite('FocusManager', function () { test('non-registered node subelement focus()ed nearest node has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById( + 'testFocusableGroup1.node2.unregisteredChild1', + ).tabIndex = -1; document .getElementById('testFocusableGroup1.node2.unregisteredChild1') @@ -3745,10 +4422,12 @@ suite('FocusManager', function () { }); test('non-registered tree focus()ed has no focus', function () { + document.getElementById('testUnregisteredFocusableGroup3').tabIndex = + -1; + document.getElementById('testUnregisteredFocusableGroup3').focus(); assert.isNull(this.focusManager.getFocusedNode()); - const rootElem = document.getElementById( 'testUnregisteredFocusableGroup3', ); @@ -3763,12 +4442,15 @@ suite('FocusManager', function () { }); test('non-registered tree node focus()ed has no focus', function () { + document.getElementById( + 'testUnregisteredFocusableGroup3.node1', + ).tabIndex = -1; + document .getElementById('testUnregisteredFocusableGroup3.node1') .focus(); assert.isNull(this.focusManager.getFocusedNode()); - const nodeElem = document.getElementById( 'testUnregisteredFocusableGroup3.node1', ); @@ -3784,6 +4466,7 @@ suite('FocusManager', function () { test('unfocusable element focus()ed after registered node focused original node has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testUnfocusableElement').focus(); @@ -3814,6 +4497,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3834,6 +4518,7 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with no prev focus removes focus', function () { this.focusManager.registerTree(this.testFocusableGroup1); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3853,6 +4538,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node prior removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableGroup1.node1').focus(); @@ -3885,6 +4572,8 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node recently removes focus from removed tree', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3917,6 +4606,9 @@ suite('FocusManager', function () { test('unregistered tree focus()ed with prev node after unregistering removes active indicator', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.unregisterTree(this.testFocusableGroup1); @@ -3949,6 +4641,9 @@ suite('FocusManager', function () { test('focus() multiple nodes in same tree with switches ensure passive focus has gone', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node2').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -3971,6 +4666,9 @@ suite('FocusManager', function () { test('registered tree focus()ed other tree node passively focused tree node now has active property', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1').tabIndex = -1; document.getElementById('testFocusableGroup1.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4004,6 +4702,9 @@ suite('FocusManager', function () { test('focus on root, node in diff tree, then node in first tree; root should have focus gone', function () { this.focusManager.registerTree(this.testFocusableGroup1); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup1.node1').tabIndex = -1; document.getElementById('testFocusableGroup1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4034,6 +4735,7 @@ suite('FocusManager', function () { test('nested tree focus()ed with no prev root has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4').tabIndex = -1; document.getElementById('testFocusableNestedGroup4').focus(); @@ -4053,6 +4755,8 @@ suite('FocusManager', function () { test('nested tree node focus()ed with no prev focus node has active focus', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -4071,6 +4775,9 @@ suite('FocusManager', function () { test('nested tree node focus()ed after parent focused prev has passive node has active', function () { this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.registerTree(this.testFocusableNestedGroup4); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableNestedGroup4.node1').tabIndex = + -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableNestedGroup4.node1').focus(); @@ -4104,6 +4811,7 @@ suite('FocusManager', function () { test('Defocusing actively focused root HTML tree switches to passive highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4124,6 +4832,7 @@ suite('FocusManager', function () { test('Defocusing actively focused HTML tree node switches to passive highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4144,6 +4853,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); @@ -4163,6 +4873,8 @@ suite('FocusManager', function () { test('Refocusing actively focused root HTML tree restores to active highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableTree2').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableTree2').focus(); @@ -4187,6 +4899,8 @@ suite('FocusManager', function () { test('Refocusing actively focused HTML tree node restores to active highlight', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -4214,6 +4928,8 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableNestedTree4); this.focusManager.focusNode(this.testFocusableNestedTree4Node1); + document.getElementById('testUnregisteredFocusableTree3').tabIndex = -1; + document.getElementById('testFocusableNestedTree4.node1').tabIndex = -1; document.getElementById('testUnregisteredFocusableTree3').focus(); document.getElementById('testFocusableNestedTree4.node1').focus(); @@ -4316,6 +5032,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusTree(this.testFocusableTree2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); @@ -4416,6 +5133,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); @@ -4447,6 +5165,7 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG focusTree()ed correctly updates getFocusedTree() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.focusTree(this.testFocusableGroup2); @@ -4481,6 +5200,7 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG focusNode()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); this.focusManager.focusNode(this.testFocusableGroup2Node1); @@ -4513,6 +5233,8 @@ suite('FocusManager', function () { test('HTML DOM focus()ed then SVG DOM focus()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); document.getElementById('testFocusableGroup2.node1').focus(); @@ -4617,6 +5339,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusTree(this.testFocusableGroup2); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); @@ -4717,6 +5440,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableGroup2Node1); + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableTree2.node1').focus(); @@ -4748,6 +5472,7 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML focusTree()ed correctly updates getFocusedTree() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.focusTree(this.testFocusableTree2); @@ -4782,6 +5507,7 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML focusNode()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); this.focusManager.focusNode(this.testFocusableTree2Node1); @@ -4814,6 +5540,8 @@ suite('FocusManager', function () { test('SVG DOM focus()ed then HTML DOM focus()ed correctly updates getFocusedNode() and indicators', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; + document.getElementById('testFocusableTree2.node1').tabIndex = -1; document.getElementById('testFocusableGroup2.node1').focus(); document.getElementById('testFocusableTree2.node1').focus(); @@ -4848,6 +5576,21 @@ suite('FocusManager', function () { /* Ephemeral focus tests. */ suite('takeEphemeralFocus()', function () { + setup(function () { + // Ensure ephemeral-specific elements are focusable. + document.getElementById('nonTreeElementForEphemeralFocus').tabIndex = -1; + document.getElementById('nonTreeGroupForEphemeralFocus').tabIndex = -1; + }); + teardown(function () { + // Ensure ephemeral-specific elements have their tab indexes reset for a clean state. + document + .getElementById('nonTreeElementForEphemeralFocus') + .removeAttribute('tabindex'); + document + .getElementById('nonTreeGroupForEphemeralFocus') + .removeAttribute('tabindex'); + }); + test('with no focused node does not change states', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); @@ -4982,6 +5725,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; const ephemeralElement = document.getElementById( 'nonTreeGroupForEphemeralFocus', ); @@ -5156,6 +5900,7 @@ suite('FocusManager', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); + document.getElementById('testFocusableGroup2.node1').tabIndex = -1; const ephemeralElement = document.getElementById( 'nonTreeGroupForEphemeralFocus', ); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 09ef8820f0e..208c2995596 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -39,97 +39,76 @@
-
+
Focusable tree 1 -
+
Tree 1 node 1 -
+
Tree 1 node 1 child 1
+ style="margin-left: 3em"> Tree 1 node 1 child 1 child 1 (unregistered)
-
+
Tree 1 node 2
+ style="margin-left: 2em"> Tree 1 node 2 child 2 (unregistered)
-
+
Tree 1 child 1 (unregistered)
-
+
Focusable tree 2 -
+
Tree 2 node 1 -
+
Nested tree 4 -
+
Tree 4 node 1 (nested)
+ style="margin-left: 4em"> Tree 4 node 1 child 1 (unregistered)
-
+
Nested tree 5 -
+
Tree 5 node 1 (nested)
-
+
Unregistered tree 3 -
+
Tree 3 node 1 (unregistered)
Unfocusable element
-
+
- - + + Group 1 node 1 - + Tree 1 node 1 child 1 - + Group 1 node 2 - + Tree 1 node 2 child 2 (unregistered) @@ -137,27 +116,27 @@ - - + + Group 2 node 1 - - + + Group 4 node 1 (nested) - - + + Tree 3 node 1 (unregistered) - +