release: merge branch develop into rc/v12.1.0

This commit is contained in:
Maribeth Moffatt
2025-05-29 15:04:58 -07:00
47 changed files with 2844 additions and 659 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<WorkspaceCommentCopyData>,
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();
}

View File

@@ -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};

View File

@@ -505,6 +505,6 @@ input[type=number] {
.blocklyIconGroup,
.blocklyTextarea
) {
outline-width: 0px;
outline: none;
}
`;

View File

@@ -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<T>(
@@ -199,10 +220,12 @@ export function showPositionedByBlock<T>(
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<T>(
* @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<T>(
field: Field<T>,
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<T>(
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<T>(
// 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 = {

View File

@@ -312,7 +312,6 @@ export abstract class Field<T = any>
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()) {

View File

@@ -212,6 +212,17 @@ export class FieldImage extends Field<string> {
}
}
/**
* 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.

View File

@@ -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<IFocusableTree> {
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.');
}
}

View File

@@ -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(),
);

View File

@@ -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<IFocusableTree> = [];
private registeredTrees: Array<TreeRegistration> = [];
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;
}
}
/**
@@ -424,14 +520,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 +571,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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<BlockSvg> {
* @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<BlockSvg> {
* 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<BlockSvg> {
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<BlockSvg> {
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;
}

View File

@@ -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

View File

@@ -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<Field<any>> {
* @returns The next field or input in the given field's block.
*/
getNextSibling(current: Field<any>): 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<Field<any>> {
* @returns The preceding field or input in the given field's block.
*/
getPreviousSibling(current: Field<any>): 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<Field<any>> {
isNavigable(current: Field<any>): boolean {
return (
current.canBeFocused() &&
current.isVisible() &&
(current.isClickable() || current.isCurrentlyEditable()) &&
!(
current.getSourceBlock()?.isSimpleReporter() &&

View File

@@ -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<Icon> {
/**
* 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;
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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();

View File

@@ -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<T> = INavigationPolicy<T>[];
@@ -27,6 +28,7 @@ export class Navigator {
new FieldNavigationPolicy(),
new ConnectionNavigationPolicy(),
new WorkspaceNavigationPolicy(),
new IconNavigationPolicy(),
];
/**

View File

@@ -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,
},

View File

@@ -10,13 +10,22 @@ import {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import * as eventUtils from './events/utils.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,7 +70,7 @@ export function registerDelete() {
return (
!workspace.isReadOnly() &&
focused != null &&
isDeletable(focused) &&
isIDeletable(focused) &&
focused.isDeletable() &&
!Gesture.inProgress()
);
@@ -75,7 +84,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 +100,39 @@ let copyData: ICopyData | null = null;
let copyWorkspace: WorkspaceSvg | null = null;
let copyCoords: Coordinate | null = null;
/**
* Determine if a focusable node can be copied using cut or copy.
*
* 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 .isMovable() and .isDeletable() must return
* true (i.e., can currently 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<ICopyData> & IDeletable & IDraggable {
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.
*/
@@ -109,11 +151,7 @@ export function registerCopy() {
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
focused != null &&
isDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable() &&
!!focused &&
isCopyable(focused)
);
},
@@ -123,7 +161,7 @@ export function registerCopy() {
e.preventDefault();
workspace.hideChaff();
const focused = scope.focusedNode;
if (!focused || !isCopyable(focused)) return false;
if (!focused || !isICopyable(focused)) return false;
copyData = focused.toCopyData();
copyWorkspace =
focused.workspace instanceof WorkspaceSvg
@@ -157,13 +195,11 @@ export function registerCut() {
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
focused != null &&
isDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable() &&
!!focused &&
isCopyable(focused) &&
!focused.workspace.isFlyout
// Extra criteria for cut (not just copy):
!focused.workspace.isFlyout &&
focused.isDeletable()
);
},
callback(workspace, e, shortcut, scope) {
@@ -176,9 +212,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;
@@ -212,8 +248,22 @@ export function registerPaste() {
preconditionFn(workspace) {
return !workspace.isReadOnly() && !Gesture.inProgress();
},
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.

View File

@@ -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() || [];
}
/**

View File

@@ -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'];

View File

@@ -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'];

View File

@@ -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. */

View File

@@ -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);
}
/**

View File

@@ -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,12 @@ 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.
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.
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);

477
package-lock.json generated
View File

@@ -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/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",
"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": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
"readdirp": "^4.0.1"
},
"engines": {
"node": ">=12"
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/mocha/node_modules/glob": {
"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": {
"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": ">=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,6 +7432,44 @@
"node": ">=10"
}
},
"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": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"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,
"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": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/mocha/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -7365,33 +7485,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"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==",
"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"
},
"engines": {
"node": ">=10"
}
},
"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==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/monaco-editor": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz",
@@ -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"

View File

@@ -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",

View File

@@ -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);
}
});

View File

@@ -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');

View File

@@ -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

View File

@@ -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);

View File

@@ -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),

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -39,97 +39,76 @@
<div id="mocha"></div>
<div id="failureCount" style="display: none" tests_failed="unset"></div>
<div id="failureMessages" style="display: none"></div>
<div id="testFocusableTree1" tabindex="-1">
<div id="testFocusableTree1">
Focusable tree 1
<div id="testFocusableTree1.node1" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree1.node1" style="margin-left: 1em">
Tree 1 node 1
<div
id="testFocusableTree1.node1.child1"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableTree1.node1.child1" style="margin-left: 2em">
Tree 1 node 1 child 1
<div
id="testFocusableTree1.node1.child1.unregisteredChild1"
style="margin-left: 3em"
tabindex="-1">
style="margin-left: 3em">
Tree 1 node 1 child 1 child 1 (unregistered)
</div>
</div>
</div>
<div id="testFocusableTree1.node2" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree1.node2" style="margin-left: 1em">
Tree 1 node 2
<div
id="testFocusableTree1.node2.unregisteredChild1"
style="margin-left: 2em"
tabindex="-1">
style="margin-left: 2em">
Tree 1 node 2 child 2 (unregistered)
</div>
</div>
<div
id="testFocusableTree1.unregisteredChild1"
style="margin-left: 1em"
tabindex="-1">
<div id="testFocusableTree1.unregisteredChild1" style="margin-left: 1em">
Tree 1 child 1 (unregistered)
</div>
</div>
<div id="testFocusableTree2" tabindex="-1">
<div id="testFocusableTree2">
Focusable tree 2
<div id="testFocusableTree2.node1" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableTree2.node1" style="margin-left: 1em">
Tree 2 node 1
<div
id="testFocusableNestedTree4"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableNestedTree4" style="margin-left: 2em">
Nested tree 4
<div
id="testFocusableNestedTree4.node1"
style="margin-left: 3em"
tabindex="-1">
<div id="testFocusableNestedTree4.node1" style="margin-left: 3em">
Tree 4 node 1 (nested)
<div
id="testFocusableNestedTree4.node1.unregisteredChild1"
style="margin-left: 4em"
tabindex="-1">
style="margin-left: 4em">
Tree 4 node 1 child 1 (unregistered)
</div>
</div>
</div>
</div>
<div id="testFocusableNestedTree5" style="margin-left: 1em" tabindex="-1">
<div id="testFocusableNestedTree5" style="margin-left: 1em">
Nested tree 5
<div
id="testFocusableNestedTree5.node1"
style="margin-left: 2em"
tabindex="-1">
<div id="testFocusableNestedTree5.node1" style="margin-left: 2em">
Tree 5 node 1 (nested)
</div>
</div>
</div>
<div id="testUnregisteredFocusableTree3" tabindex="-1">
<div id="testUnregisteredFocusableTree3">
Unregistered tree 3
<div
id="testUnregisteredFocusableTree3.node1"
style="margin-left: 1em"
tabindex="-1">
<div id="testUnregisteredFocusableTree3.node1" style="margin-left: 1em">
Tree 3 node 1 (unregistered)
</div>
</div>
<div id="testUnfocusableElement">Unfocusable element</div>
<div id="nonTreeElementForEphemeralFocus" tabindex="-1" />
<div id="nonTreeElementForEphemeralFocus" />
<svg width="250" height="250">
<g id="testFocusableGroup1" tabindex="-1">
<g id="testFocusableGroup1.node1" tabindex="-1">
<g id="testFocusableGroup1">
<g id="testFocusableGroup1.node1">
<rect x="0" y="0" width="250" height="30" fill="grey" />
<text x="10" y="20" class="svgText">Group 1 node 1</text>
<g id="testFocusableGroup1.node1.child1" tabindex="-1">
<g id="testFocusableGroup1.node1.child1">
<rect x="0" y="30" width="250" height="30" fill="lightgrey" />
<text x="10" y="50" class="svgText">Tree 1 node 1 child 1</text>
</g>
</g>
<g id="testFocusableGroup1.node2" tabindex="-1">
<g id="testFocusableGroup1.node2">
<rect x="0" y="60" width="250" height="30" fill="grey" />
<text x="10" y="80" class="svgText">Group 1 node 2</text>
<g id="testFocusableGroup1.node2.unregisteredChild1" tabindex="-1">
<g id="testFocusableGroup1.node2.unregisteredChild1">
<rect x="0" y="90" width="250" height="30" fill="lightgrey" />
<text x="10" y="110" class="svgText">
Tree 1 node 2 child 2 (unregistered)
@@ -137,27 +116,27 @@
</g>
</g>
</g>
<g id="testFocusableGroup2" tabindex="-1">
<g id="testFocusableGroup2.node1" tabindex="-1">
<g id="testFocusableGroup2">
<g id="testFocusableGroup2.node1">
<rect x="0" y="120" width="250" height="30" fill="grey" />
<text x="10" y="140" class="svgText">Group 2 node 1</text>
</g>
<g id="testFocusableNestedGroup4" tabindex="-1">
<g id="testFocusableNestedGroup4.node1" tabindex="-1">
<g id="testFocusableNestedGroup4">
<g id="testFocusableNestedGroup4.node1">
<rect x="0" y="150" width="250" height="30" fill="lightgrey" />
<text x="10" y="170" class="svgText">Group 4 node 1 (nested)</text>
</g>
</g>
</g>
<g id="testUnregisteredFocusableGroup3" tabindex="-1">
<g id="testUnregisteredFocusableGroup3.node1" tabindex="-1">
<g id="testUnregisteredFocusableGroup3">
<g id="testUnregisteredFocusableGroup3.node1">
<rect x="0" y="180" width="250" height="30" fill="grey" />
<text x="10" y="200" class="svgText">
Tree 3 node 1 (unregistered)
</text>
</g>
</g>
<g id="nonTreeGroupForEphemeralFocus" tabindex="-1"></g>
<g id="nonTreeGroupForEphemeralFocus"></g>
</svg>
<!-- Load mocha et al. before Blockly and the test modules so that
we can safely import the test modules that make calls
@@ -240,6 +219,7 @@
import './jso_deserialization_test.js';
import './jso_serialization_test.js';
import './json_test.js';
import './keyboard_navigation_controller_test.js';
import './layering_test.js';
import './blocks/lists_test.js';
import './blocks/logic_ternary_test.js';

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/chai.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
suite('Keyboard Navigation Controller', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.keyboardNavigationController.setIsActive(false);
});
teardown(function () {
sharedTestTeardown.call(this);
Blockly.keyboardNavigationController.setIsActive(false);
});
test('Setting active keyboard navigation adds css class', function () {
Blockly.keyboardNavigationController.setIsActive(true);
assert.isTrue(
document.body.classList.contains('blocklyKeyboardNavigation'),
);
});
test('Disabling active keyboard navigation removes css class', function () {
Blockly.keyboardNavigationController.setIsActive(false);
assert.isFalse(
document.body.classList.contains('blocklyKeyboardNavigation'),
);
});
});

View File

@@ -72,6 +72,20 @@ suite('Navigation', function () {
'tooltip': '',
'helpUrl': '',
},
{
'type': 'double_value_input',
'message0': '%1 %2',
'args0': [
{
'type': 'input_value',
'name': 'NAME1',
},
{
'type': 'input_value',
'name': 'NAME2',
},
],
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.navigator = this.workspace.getNavigator();
@@ -80,6 +94,7 @@ suite('Navigation', function () {
const statementInput3 = this.workspace.newBlock('input_statement');
const statementInput4 = this.workspace.newBlock('input_statement');
const fieldWithOutput = this.workspace.newBlock('field_input');
const doubleValueInput = this.workspace.newBlock('double_value_input');
const valueInput = this.workspace.newBlock('value_input');
statementInput1.nextConnection.connect(statementInput2.previousConnection);
@@ -97,6 +112,7 @@ suite('Navigation', function () {
statementInput4: statementInput4,
fieldWithOutput: fieldWithOutput,
valueInput: valueInput,
doubleValueInput,
};
});
teardown(function () {
@@ -164,6 +180,30 @@ suite('Navigation', function () {
'tooltip': '',
'helpUrl': '',
},
{
'type': 'hidden_field',
'message0': '%1 %2 %3',
'args0': [
{
'type': 'field_input',
'name': 'ONE',
'text': 'default',
},
{
'type': 'field_input',
'name': 'TWO',
'text': 'default',
},
{
'type': 'field_input',
'name': 'THREE',
'text': 'default',
},
],
'colour': 230,
'tooltip': '',
'helpUrl': '',
},
{
'type': 'fields_and_input2',
'message0': '%1 %2 %3 %4 bye',
@@ -222,17 +262,116 @@ suite('Navigation', function () {
'helpUrl': '',
'nextStatement': null,
},
{
'type': 'hidden_input',
'message0': '%1 hi %2 %3 %4 %5 %6',
'args0': [
{
'type': 'field_input',
'name': 'ONE',
'text': 'default',
},
{
'type': 'input_dummy',
},
{
'type': 'field_input',
'name': 'TWO',
'text': 'default',
},
{
'type': 'input_value',
'name': 'SECOND',
},
{
'type': 'field_input',
'name': 'THREE',
'text': 'default',
},
{
'type': 'input_value',
'name': 'THIRD',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'tooltip': '',
'helpUrl': '',
},
{
'type': 'buttons',
'message0': 'If %1 %2 Then %3 %4 more %5 %6 %7',
'args0': [
{
'type': 'field_image',
'name': 'BUTTON1',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
{
'type': 'input_value',
'name': 'VALUE1',
'check': '',
},
{
'type': 'field_image',
'name': 'BUTTON2',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
{
'type': 'input_dummy',
'name': 'DUMMY1',
'check': '',
},
{
'type': 'input_value',
'name': 'VALUE2',
'check': '',
},
{
'type': 'input_statement',
'name': 'STATEMENT1',
'check': 'Number',
},
{
'type': 'field_image',
'name': 'BUTTON3',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'tooltip': '',
'helpUrl': '',
},
]);
const noNextConnection = this.workspace.newBlock('top_connection');
const fieldAndInputs = this.workspace.newBlock('fields_and_input');
const twoFields = this.workspace.newBlock('two_fields');
const fieldAndInputs2 = this.workspace.newBlock('fields_and_input2');
const noPrevConnection = this.workspace.newBlock('start_block');
const hiddenField = this.workspace.newBlock('hidden_field');
const hiddenInput = this.workspace.newBlock('hidden_input');
this.blocks.noNextConnection = noNextConnection;
this.blocks.fieldAndInputs = fieldAndInputs;
this.blocks.twoFields = twoFields;
this.blocks.fieldAndInputs2 = fieldAndInputs2;
this.blocks.noPrevConnection = noPrevConnection;
this.blocks.hiddenField = hiddenField;
this.blocks.hiddenInput = hiddenInput;
hiddenField.inputList[0].fieldRow[1].setVisible(false);
hiddenInput.inputList[1].setVisible(false);
const dummyInput = this.workspace.newBlock('dummy_input');
const dummyInputValue = this.workspace.newBlock('dummy_inputValue');
@@ -245,6 +384,39 @@ suite('Navigation', function () {
const outputNextBlock = this.workspace.newBlock('output_next');
this.blocks.secondBlock = secondBlock;
this.blocks.outputNextBlock = outputNextBlock;
const buttonBlock = this.workspace.newBlock('buttons', 'button_block');
const buttonInput1 = this.workspace.newBlock(
'field_input',
'button_input1',
);
const buttonInput2 = this.workspace.newBlock(
'field_input',
'button_input2',
);
const buttonNext = this.workspace.newBlock(
'input_statement',
'button_next',
);
buttonBlock.inputList[0].connection.connect(
buttonInput1.outputConnection,
);
buttonBlock.inputList[2].connection.connect(
buttonInput2.outputConnection,
);
buttonBlock.nextConnection.connect(buttonNext.previousConnection);
// Make buttons by adding a click handler
const clickHandler = function () {
return;
};
buttonBlock.getField('BUTTON1').setOnClickHandler(clickHandler);
buttonBlock.getField('BUTTON2').setOnClickHandler(clickHandler);
buttonBlock.getField('BUTTON3').setOnClickHandler(clickHandler);
this.blocks.buttonBlock = buttonBlock;
this.blocks.buttonInput1 = buttonInput1;
this.blocks.buttonInput2 = buttonInput2;
this.blocks.buttonNext = buttonNext;
this.workspace.cleanUp();
});
suite('Next', function () {
@@ -275,16 +447,9 @@ suite('Navigation', function () {
assert.equal(nextNode, prevConnection);
});
test('fromInputToInput', function () {
const input = this.blocks.statementInput1.inputList[0];
const input = this.blocks.doubleValueInput.inputList[0];
const inputConnection =
this.blocks.statementInput1.inputList[1].connection;
const nextNode = this.navigator.getNextSibling(input.connection);
assert.equal(nextNode, inputConnection);
});
test('fromInputToStatementInput', function () {
const input = this.blocks.fieldAndInputs2.inputList[1];
const inputConnection =
this.blocks.fieldAndInputs2.inputList[2].connection;
this.blocks.doubleValueInput.inputList[1].connection;
const nextNode = this.navigator.getNextSibling(input.connection);
assert.equal(nextNode, inputConnection);
});
@@ -322,6 +487,69 @@ suite('Navigation', function () {
const nextNode = this.navigator.getNextSibling(field);
assert.isNull(nextNode);
});
test('skipsHiddenField', function () {
const field = this.blocks.hiddenField.inputList[0].fieldRow[0];
const field2 = this.blocks.hiddenField.inputList[0].fieldRow[2];
const nextNode = this.navigator.getNextSibling(field);
assert.equal(nextNode.name, field2.name);
});
test('skipsHiddenInput', function () {
const field = this.blocks.hiddenInput.inputList[0].fieldRow[0];
const nextNode = this.navigator.getNextSibling(field);
assert.equal(
nextNode,
this.blocks.hiddenInput.inputList[2].fieldRow[0],
);
});
test('from icon to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const nextNode = this.navigator.getNextSibling(icons[0]);
assert.equal(nextNode, icons[1]);
});
test('from icon to field', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const nextNode = this.navigator.getNextSibling(icons[1]);
assert.equal(
nextNode,
this.blocks.statementInput1.inputList[0].fieldRow[0],
);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const nextNode = this.navigator.getNextSibling(icons[0]);
assert.isNull(nextNode);
});
test('fromBlockToFieldInNextInput', function () {
const field = this.blocks.buttonBlock.getField('BUTTON2');
const nextNode = this.navigator.getNextSibling(
this.blocks.buttonInput1,
);
assert.equal(nextNode, field);
});
test('fromBlockToFieldSkippingInput', function () {
const field = this.blocks.buttonBlock.getField('BUTTON3');
const nextNode = this.navigator.getNextSibling(
this.blocks.buttonInput2,
);
assert.equal(nextNode, field);
});
test('skipsChildrenOfCollapsedBlocks', function () {
this.blocks.buttonBlock.setCollapsed(true);
const nextNode = this.navigator.getNextSibling(this.blocks.buttonBlock);
assert.equal(nextNode.id, this.blocks.buttonNext.id);
});
test('fromFieldSkipsHiddenInputs', function () {
this.blocks.buttonBlock.inputList[2].setVisible(false);
const fieldStart = this.blocks.buttonBlock.getField('BUTTON2');
const fieldEnd = this.blocks.buttonBlock.getField('BUTTON3');
const nextNode = this.navigator.getNextSibling(fieldStart);
assert.equal(nextNode.name, fieldEnd.name);
});
});
suite('Previous', function () {
@@ -356,6 +584,11 @@ suite('Navigation', function () {
assert.equal(prevNode, this.blocks.statementInput1);
});
test('fromInputToField', function () {
// Disconnect the block that was connected to the input we're testing,
// because we only navigate to/from empty input connections (if they're
// connected navigation targets the connected block, bypassing the
// connection).
this.blocks.fieldWithOutput.outputConnection.disconnect();
const input = this.blocks.statementInput1.inputList[0];
const prevNode = this.navigator.getPreviousSibling(input.connection);
assert.equal(prevNode, input.fieldRow[1]);
@@ -366,9 +599,9 @@ suite('Navigation', function () {
assert.isNull(prevNode);
});
test('fromInputToInput', function () {
const input = this.blocks.fieldAndInputs2.inputList[2];
const input = this.blocks.doubleValueInput.inputList[1];
const inputConnection =
this.blocks.fieldAndInputs2.inputList[1].connection;
this.blocks.doubleValueInput.inputList[0].connection;
const prevNode = this.navigator.getPreviousSibling(input.connection);
assert.equal(prevNode, inputConnection);
});
@@ -400,6 +633,70 @@ suite('Navigation', function () {
const prevNode = this.navigator.getPreviousSibling(field);
assert.equal(prevNode, field2);
});
test('skipsHiddenField', function () {
const field = this.blocks.hiddenField.inputList[0].fieldRow[2];
const field2 = this.blocks.hiddenField.inputList[0].fieldRow[0];
const prevNode = this.navigator.getPreviousSibling(field);
assert.equal(prevNode.name, field2.name);
});
test('skipsHiddenInput', function () {
const field = this.blocks.hiddenInput.inputList[2].fieldRow[0];
const nextNode = this.navigator.getPreviousSibling(field);
assert.equal(
nextNode,
this.blocks.hiddenInput.inputList[0].fieldRow[0],
);
});
test('from icon to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const prevNode = this.navigator.getPreviousSibling(icons[1]);
assert.equal(prevNode, icons[0]);
});
test('from field to icon', function () {
this.blocks.statementInput1.setCommentText('test');
this.blocks.statementInput1.setWarningText('test');
const icons = this.blocks.statementInput1.getIcons();
const prevNode = this.navigator.getPreviousSibling(
this.blocks.statementInput1.inputList[0].fieldRow[0],
);
assert.equal(prevNode, icons[1]);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const prevNode = this.navigator.getPreviousSibling(icons[0]);
assert.isNull(prevNode);
});
test('fromBlockToFieldInSameInput', function () {
const field = this.blocks.buttonBlock.getField('BUTTON1');
const prevNode = this.navigator.getPreviousSibling(
this.blocks.buttonInput1,
);
assert.equal(prevNode, field);
});
test('fromBlockToFieldInPrevInput', function () {
const field = this.blocks.buttonBlock.getField('BUTTON2');
const prevNode = this.navigator.getPreviousSibling(
this.blocks.buttonInput2,
);
assert.equal(prevNode, field);
});
test('skipsChildrenOfCollapsedBlocks', function () {
this.blocks.buttonBlock.setCollapsed(true);
const prevNode = this.navigator.getPreviousSibling(
this.blocks.buttonNext,
);
assert.equal(prevNode.id, this.blocks.buttonBlock.id);
});
test('fromFieldSkipsHiddenInputs', function () {
this.blocks.buttonBlock.inputList[2].setVisible(false);
const fieldStart = this.blocks.buttonBlock.getField('BUTTON3');
const fieldEnd = this.blocks.buttonBlock.getField('BUTTON2');
const nextNode = this.navigator.getPreviousSibling(fieldStart);
assert.equal(nextNode.name, fieldEnd.name);
});
});
suite('In', function () {
@@ -428,10 +725,10 @@ suite('Navigation', function () {
const inNode = this.navigator.getFirstChild(input.connection);
assert.equal(inNode, previousConnection);
});
test('fromBlockToField', function () {
const field = this.blocks.valueInput.getField('NAME');
test('fromBlockToInput', function () {
const connection = this.blocks.valueInput.inputList[0].connection;
const inNode = this.navigator.getFirstChild(this.blocks.valueInput);
assert.equal(inNode, field);
assert.equal(inNode, connection);
});
test('fromBlockToField', function () {
const inNode = this.navigator.getFirstChild(
@@ -448,7 +745,10 @@ suite('Navigation', function () {
const inNode = this.navigator.getFirstChild(
this.blocks.dummyInputValue,
);
assert.equal(inNode, null);
assert.equal(
inNode,
this.blocks.dummyInputValue.inputList[1].connection,
);
});
test('fromOuputToNull', function () {
const output = this.blocks.fieldWithOutput.outputConnection;
@@ -468,6 +768,23 @@ suite('Navigation', function () {
const inNode = this.navigator.getFirstChild(this.emptyWorkspace);
assert.isNull(inNode);
});
test('from block to icon', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const inNode = this.navigator.getFirstChild(this.blocks.dummyInput);
assert.equal(inNode, icons[0]);
});
test('from icon to null', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const inNode = this.navigator.getFirstChild(icons[0]);
assert.isNull(inNode);
});
test('skipsChildrenOfCollapsedBlocks', function () {
this.blocks.buttonBlock.setCollapsed(true);
const inNode = this.navigator.getFirstChild(this.blocks.buttonBlock);
assert.isNull(inNode);
});
});
suite('Out', function () {
@@ -487,13 +804,10 @@ suite('Navigation', function () {
const outNode = this.navigator.getParent(input.connection);
assert.equal(outNode, this.blocks.statementInput1);
});
test('fromOutputToInput', function () {
test('fromOutputToBlock', function () {
const output = this.blocks.fieldWithOutput.outputConnection;
const outNode = this.navigator.getParent(output);
assert.equal(
outNode,
this.blocks.statementInput1.inputList[0].connection,
);
assert.equal(outNode, this.blocks.fieldWithOutput);
});
test('fromOutputToBlock', function () {
const output = this.blocks.fieldWithOutput2.outputConnection;
@@ -505,43 +819,29 @@ suite('Navigation', function () {
const outNode = this.navigator.getParent(field);
assert.equal(outNode, this.blocks.statementInput1);
});
test('fromPreviousToInput', function () {
const previous = this.blocks.statementInput3.previousConnection;
const inputConnection =
this.blocks.statementInput2.inputList[1].connection;
const outNode = this.navigator.getParent(previous);
assert.equal(outNode, inputConnection);
});
test('fromPreviousToBlock', function () {
const previous = this.blocks.statementInput2.previousConnection;
const outNode = this.navigator.getParent(previous);
assert.equal(outNode, this.blocks.statementInput1);
});
test('fromNextToInput', function () {
const next = this.blocks.statementInput3.nextConnection;
const inputConnection =
this.blocks.statementInput2.inputList[1].connection;
const outNode = this.navigator.getParent(next);
assert.equal(outNode, inputConnection);
assert.equal(outNode, this.blocks.statementInput2);
});
test('fromNextToBlock', function () {
const next = this.blocks.statementInput2.nextConnection;
const outNode = this.navigator.getParent(next);
assert.equal(outNode, this.blocks.statementInput1);
assert.equal(outNode, this.blocks.statementInput2);
});
test('fromNextToBlock_NoPreviousConnection', function () {
const next = this.blocks.secondBlock.nextConnection;
const outNode = this.navigator.getParent(next);
assert.equal(outNode, this.blocks.noPrevConnection);
assert.equal(outNode, this.blocks.secondBlock);
});
/**
* This is where there is a block with both an output connection and a
* next connection attached to an input.
*/
test('fromNextToInput_OutputAndPreviousConnection', function () {
test('fromNextToBlock_OutputAndPreviousConnection', function () {
const next = this.blocks.outputNextBlock.nextConnection;
const outNode = this.navigator.getParent(next);
assert.equal(outNode, this.blocks.secondBlock.inputList[0].connection);
assert.equal(outNode, this.blocks.outputNextBlock);
});
test('fromBlockToWorkspace', function () {
const outNode = this.navigator.getParent(this.blocks.statementInput2);
@@ -565,6 +865,12 @@ suite('Navigation', function () {
const outNode = this.navigator.getParent(this.blocks.outputNextBlock);
assert.equal(outNode, inputConnection);
});
test('from icon to block', function () {
this.blocks.dummyInput.setCommentText('test');
const icons = this.blocks.dummyInput.getIcons();
const outNode = this.navigator.getParent(icons[0]);
assert.equal(outNode, this.blocks.dummyInput);
});
});
});
});

View File

@@ -13,9 +13,26 @@ import {
suite('WidgetDiv', 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('positionWithAnchor', function () {
@@ -269,4 +286,142 @@ suite('WidgetDiv', function () {
});
});
});
suite('Keyboard Shortcuts', function () {
test('Escape dismisses WidgetDiv', function () {
let hidden = false;
Blockly.WidgetDiv.show(
this,
false,
() => {
hidden = true;
},
this.workspace,
false,
);
assert.isFalse(hidden);
Blockly.WidgetDiv.getDiv().dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27, // example values.
}),
);
assert.isTrue(hidden);
});
});
suite('show()', function () {
test('shows nowhere', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.WidgetDiv.show(field, false, () => {});
// By default the div will not have a position.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, 'block');
assert.strictEqual(widgetDivElem.style.left, '');
assert.strictEqual(widgetDivElem.style.top, '');
});
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.WidgetDiv.show(field, false, () => {});
// 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.WidgetDiv.show(field, false, () => {}, 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 widget div', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.getFocusManager().focusNode(block);
Blockly.WidgetDiv.show(field, false, () => {}, null, true);
// Managing ephemeral focus won't change getFocusedNode() but will change the actual element
// with DOM focus.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block);
assert.strictEqual(document.activeElement, widgetDivElem);
});
});
suite('hide()', function () {
test('initially keeps display empty', function () {
Blockly.WidgetDiv.hide();
// The display property starts as empty and stays that way until an owner is attached.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, '');
});
test('for showing div hides div', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.WidgetDiv.show(field, false, () => {});
Blockly.WidgetDiv.hide();
// Technically this will trigger a CSS animation, but the property is still set to 0.
const widgetDivElem = document.querySelector('.blocklyWidgetDiv');
assert.strictEqual(widgetDivElem.style.display, 'none');
});
test('for showing div and hide callback calls callback', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
const onHideCallback = sinon.stub();
Blockly.WidgetDiv.show(field, false, onHideCallback);
Blockly.WidgetDiv.hide();
// Hiding the div should trigger the hide callback.
assert.strictEqual(onHideCallback.callCount, 1);
});
test('for showing div without ephemeral focus does not change focus', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.getFocusManager().focusNode(block);
Blockly.WidgetDiv.show(field, false, () => {}, null, false);
Blockly.WidgetDiv.hide();
// 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('for showing div with ephemeral focus restores DOM focus', function () {
const block = this.setUpBlockWithField();
const field = Array.from(block.getFields())[0];
Blockly.getFocusManager().focusNode(block);
Blockly.WidgetDiv.show(field, false, () => {}, null, true);
Blockly.WidgetDiv.hide();
// 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);
});
});
});