diff --git a/packages/blockly/core/blockly.ts b/packages/blockly/core/blockly.ts index 4c242ae1174..b182509c4ff 100644 --- a/packages/blockly/core/blockly.ts +++ b/packages/blockly/core/blockly.ts @@ -428,6 +428,7 @@ Names.prototype.populateProcedures = function ( export * from './interfaces/i_json_block_definition.js'; export * from './interfaces/i_navigation_policy.js'; +export * from './interfaces/i_navigator.js'; export * from './keyboard_nav/navigation_policies/block_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/bubble_navigation_policy.js'; export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js'; @@ -442,6 +443,7 @@ export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_p export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js'; export * from './keyboard_nav/navigators/flyout_navigator.js'; export * from './keyboard_nav/navigators/navigator.js'; +export * from './keyboard_nav/navigators/workspace_control_navigator.js'; export * from './toast.js'; // Re-export submodules that no longer declareLegacyNamespace. diff --git a/packages/blockly/core/interfaces/i_focusable_tree.ts b/packages/blockly/core/interfaces/i_focusable_tree.ts index d3ed925caf5..2f42a42beb9 100644 --- a/packages/blockly/core/interfaces/i_focusable_tree.ts +++ b/packages/blockly/core/interfaces/i_focusable_tree.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {Navigator} from '../keyboard_nav/navigators/navigator'; import type {IFocusableNode} from './i_focusable_node.js'; +import type {INavigator} from './i_navigator.js'; /** * Represents a tree of focusable elements with its own active/passive focus @@ -125,12 +125,12 @@ export interface IFocusableTree { onTreeBlur(nextTree: IFocusableTree | null): void; /** - * Returns a Navigator instance to be used to determine the navigation order - * between IFocusableNodes contained within this IFocusableTree. Generally - * this can just be an instance of Navigator, but trees may choose to return a - * subclass to customize navigation behavior within their context. + * Returns a navigator used to determine the navigation order between + * IFocusableNodes contained within this IFocusableTree. Generally this can + * just be an instance of Navigator, but trees may choose to return a + * different INavigator to customize navigation behavior within their context. */ - getNavigator(): Navigator; + getNavigator(): INavigator; } /** diff --git a/packages/blockly/core/interfaces/i_navigator.ts b/packages/blockly/core/interfaces/i_navigator.ts new file mode 100644 index 00000000000..91035e16b5c --- /dev/null +++ b/packages/blockly/core/interfaces/i_navigator.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../block_svg.js'; +import type {IFocusableNode} from './i_focusable_node.js'; +import type {INavigationPolicy} from './i_navigation_policy.js'; + +/** + * Coordinates keyboard navigation between focusable nodes. + * + * Implemented by {@link Navigator} and specialized navigators such as + * {@link WorkspaceControlNavigator}. + */ +export interface INavigator { + /** Adds a navigation ruleset to this navigator. */ + addNavigationPolicy(policy: INavigationPolicy): void; + + /** Returns the first child of the given node, if any. */ + getFirstChild(current: IFocusableNode): IFocusableNode | null; + + /** Returns the parent of the given node, if any. */ + getParent(current: IFocusableNode): IFocusableNode | null; + + /** Returns the next sibling of the given node, if any. */ + getNextSibling(current: IFocusableNode): IFocusableNode | null; + + /** Returns the previous sibling of the given node, if any. */ + getPreviousSibling(current: IFocusableNode): IFocusableNode | null; + + /** Returns the previous node in navigation order, if any. */ + getPreviousNode(node?: IFocusableNode | null): IFocusableNode | null; + + /** Returns the node reached by navigating out, if any. */ + getOutNode(node?: IFocusableNode | null): IFocusableNode | null; + + /** Returns the next node in navigation order, if any. */ + getNextNode(node?: IFocusableNode | null): IFocusableNode | null; + + /** Returns the node reached by navigating in, if any. */ + getInNode(node?: IFocusableNode | null): IFocusableNode | null; + + /** Sets whether navigation loops when reaching the end. */ + setNavigationLoops(loops: boolean): void; + + /** Returns whether navigation loops when reaching the end. */ + getNavigationLoops(): boolean; + + /** Returns the first navigable node in the focused tree, if any. */ + getFirstNode(): IFocusableNode | null; + + /** Returns the last navigable node in the focused tree, if any. */ + getLastNode(): IFocusableNode | null; + + /** + * Returns the navigable top-level items of a tree, in navigation order. + * + * @param root The root node of the tree to list. + * @returns The navigable top-level items under the root. + */ + getNavigableItems(root?: IFocusableNode | null): IFocusableNode[]; + + /** + * Moves between top-level stacks by the given delta. + * + * @returns The stack root to focus, or null if none. + */ + navigateStacks(current: IFocusableNode, delta: number): IFocusableNode | null; + + /** Returns the source block for a focusable node, if any. */ + getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null; +} diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 112505b75bd..d73edb9e6ba 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -11,6 +11,7 @@ import {getFocusManager} from '../../focus_manager.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 type {INavigator} from '../../interfaces/i_navigator.js'; import {RenderedConnection} from '../../rendered_connection.js'; import {BlockNavigationPolicy} from '../navigation_policies/block_navigation_policy.js'; import {BubbleNavigationPolicy} from '../navigation_policies/bubble_navigation_policy.js'; @@ -38,7 +39,7 @@ export enum NavigationDirection { * Class responsible for determining where focus should move in response to * keyboard navigation commands. */ -export class Navigator { +export class Navigator implements INavigator { /** * Map from classes to a corresponding ruleset to handle navigation from * instances of that class. diff --git a/packages/blockly/core/keyboard_nav/navigators/workspace_control_navigator.ts b/packages/blockly/core/keyboard_nav/navigators/workspace_control_navigator.ts new file mode 100644 index 00000000000..9813b794679 --- /dev/null +++ b/packages/blockly/core/keyboard_nav/navigators/workspace_control_navigator.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BlockSvg} from '../../block_svg.js'; +import type {IFocusableNode} from '../../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js'; +import type {INavigator} from '../../interfaces/i_navigator.js'; + +/** + * No-op navigator for single-node workspace controls (trashcan, zoom). + */ +export class WorkspaceControlNavigator implements INavigator { + addNavigationPolicy(_policy: INavigationPolicy) {} + + getFirstChild() { + return null; + } + + getParent() { + return null; + } + + getNextSibling() { + return null; + } + + getPreviousSibling() { + return null; + } + + getPreviousNode() { + return null; + } + + getOutNode() { + return null; + } + + getNextNode() { + return null; + } + + getInNode() { + return null; + } + + setNavigationLoops(_loops: boolean) {} + + getNavigationLoops() { + return false; + } + + getFirstNode() { + return null; + } + + getLastNode() { + return null; + } + + getNavigableItems(_root?: IFocusableNode | null): IFocusableNode[] { + return []; + } + + navigateStacks() { + return null; + } + + getSourceBlockFromNode(_node: IFocusableNode | null): BlockSvg | null { + return null; + } +} diff --git a/packages/blockly/core/sprites.ts b/packages/blockly/core/sprites.ts index 300e1ce06e0..2d65e2efbd2 100644 --- a/packages/blockly/core/sprites.ts +++ b/packages/blockly/core/sprites.ts @@ -5,7 +5,7 @@ */ /** - * Contains the path to a single png tat holds the images for the trashcan + * Contains the path to a single svg that holds the images for the trashcan * as well as the zoom controls. */ export const SPRITE = { diff --git a/packages/blockly/core/trashcan.ts b/packages/blockly/core/trashcan.ts index f42a11515ca..e087f9e2294 100644 --- a/packages/blockly/core/trashcan.ts +++ b/packages/blockly/core/trashcan.ts @@ -26,8 +26,10 @@ import type {IComponent} from './interfaces/i_component'; import type {IDraggable} from './interfaces/i_draggable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IPositionable} from './interfaces/i_positionable.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; +import {WorkspaceControlNavigator} from './keyboard_nav/navigators/workspace_control_navigator.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import type {UiMetrics} from './metrics_manager.js'; import {Msg} from './msg.js'; @@ -50,8 +52,14 @@ import type {WorkspaceSvg} from './workspace_svg.js'; */ export class Trashcan extends DeleteArea - implements IAutoHideable, IPositionable, IFocusableNode, IComponent + implements + IAutoHideable, + IPositionable, + IFocusableNode, + IFocusableTree, + IComponent { + private readonly navigator = new WorkspaceControlNavigator(); /** * The id for this component that is used to register with the * ComponentManager. @@ -137,17 +145,25 @@ export class Trashcan */ createDom(): SVGElement { /* Here's the markup that will be generated: - - - - - - - - - + + + + + + + + + + + + + + */ this.svgGroup = dom.createSvgElement(Svg.G, { @@ -256,6 +272,7 @@ export class Trashcan this.blockMouseDownWhenOpenable, ); browserEvents.bind(this.svgGroup, 'pointerup', this, this.click); + getFocusManager().registerTree(this, false); return this.svgGroup; } @@ -275,7 +292,6 @@ export class Trashcan ComponentManager.Capability.DELETE_AREA, ComponentManager.Capability.DRAG_TARGET, ComponentManager.Capability.POSITIONABLE, - ComponentManager.Capability.FOCUSABLE, ], }); this.initialized = true; @@ -287,6 +303,9 @@ export class Trashcan * Unlink from all DOM elements to prevent memory leaks. */ dispose() { + if (getFocusManager().isRegistered(this)) { + getFocusManager().unregisterTree(this); + } this.workspace.getComponentManager().removeComponent('trashcan'); if (this.svgGroup) { dom.removeNode(this.svgGroup); @@ -662,7 +681,7 @@ export class Trashcan } getFocusableTree() { - return this.workspace; + return this; } onNodeFocus() {} @@ -672,6 +691,37 @@ export class Trashcan return !!this.svgGroup; } + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode(): IFocusableNode | null { + return this; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): IFocusableTree[] { + return []; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(): IFocusableNode | null { + return null; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus(): void {} + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(): void {} + + /** See IFocusableTree.getNavigator. */ + getNavigator(): WorkspaceControlNavigator { + return this.navigator; + } + performAction() { this.click(); } diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 3dbdb347098..d9375c553f6 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -52,6 +52,7 @@ import { import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import type {IMetricsManager} from './interfaces/i_metrics_manager.js'; +import type {INavigator} from './interfaces/i_navigator.js'; import type {IToolbox} from './interfaces/i_toolbox.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {Navigator} from './keyboard_nav/navigators/navigator.js'; @@ -356,7 +357,7 @@ export class WorkspaceSvg * Navigator that handles moving focus between items in this workspace in * response to keyboard navigation commands. */ - private navigator = new Navigator(); + private navigator: INavigator = new Navigator(); /** * Whether this workspace has ever been focused. Used to announce usage hints @@ -953,6 +954,7 @@ export class WorkspaceSvg } if (this.zoomControls_) { this.zoomControls_.dispose(); + this.zoomControls_ = null; } if (this.audioManager) { @@ -1016,7 +1018,7 @@ export class WorkspaceSvg addTrashcan() { this.trashcan = WorkspaceSvg.newTrashcan(this); const svgTrashcan = this.trashcan.createDom(); - this.svgGroup_.insertBefore(svgTrashcan, this.getCanvas()); + this.svgGroup_.appendChild(svgTrashcan); } /** @@ -2876,7 +2878,7 @@ export class WorkspaceSvg /** See IFocusableTree.getNestedTrees. */ getNestedTrees(): Array { - const nestedWorkspaces = common + const nestedTrees: IFocusableTree[] = common .getAllWorkspaces() .filter( (w) => w.isMutator && w.options.parentWorkspace === this, @@ -2884,10 +2886,17 @@ export class WorkspaceSvg const ownFlyout = this.getFlyout(true); if (ownFlyout) { - nestedWorkspaces.push(ownFlyout.getWorkspace()); + nestedTrees.push(ownFlyout.getWorkspace()); } - return nestedWorkspaces; + if (this.trashcan) { + nestedTrees.push(this.trashcan); + } + if (this.zoomControls_) { + nestedTrees.push(...this.zoomControls_.getFocusableControls()); + } + + return nestedTrees; } /** @@ -3073,7 +3082,7 @@ export class WorkspaceSvg * * @returns This workspace's Navigator instance. */ - getNavigator(): Navigator { + getNavigator(): INavigator { return this.navigator; } @@ -3083,7 +3092,7 @@ export class WorkspaceSvg * @param newNavigator A Navigator object to coordinate movement between * elements on the workspace. */ - setNavigator(newNavigator: Navigator) { + setNavigator(newNavigator: INavigator) { this.navigator = newNavigator; } } diff --git a/packages/blockly/core/zoom_controls.ts b/packages/blockly/core/zoom_controls.ts index 4954ee52c57..9fee4d41989 100644 --- a/packages/blockly/core/zoom_controls.ts +++ b/packages/blockly/core/zoom_controls.ts @@ -16,9 +16,11 @@ import {ComponentManager} from './component_manager.js'; import * as Css from './css.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; -import type {IComponent} from './interfaces/i_component.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {getFocusManager} from './focus_manager.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IPositionable} from './interfaces/i_positionable.js'; +import {WorkspaceControlNavigator} from './keyboard_nav/navigators/workspace_control_navigator.js'; import type {UiMetrics} from './metrics_manager.js'; import {Msg} from './msg.js'; import * as uiPosition from './positionable_helpers.js'; @@ -37,9 +39,10 @@ import type {WorkspaceSvg} from './workspace_svg.js'; * * @internal */ -abstract class ZoomControl implements IFocusableNode, IComponent { +abstract class ZoomControl implements IFocusableNode, IFocusableTree { private pointerDownHandler: browserEvents.Data; id: string; + private readonly navigator = new WorkspaceControlNavigator(); constructor( protected workspace: WorkspaceSvg, @@ -56,6 +59,7 @@ abstract class ZoomControl implements IFocusableNode, IComponent { this.id = getNextUniqueId(); this.group.id = this.id; + getFocusManager().registerTree(this, false); } /** @@ -90,7 +94,7 @@ abstract class ZoomControl implements IFocusableNode, IComponent { } getFocusableTree() { - return this.workspace; + return this; } onNodeFocus() {} @@ -101,9 +105,43 @@ abstract class ZoomControl implements IFocusableNode, IComponent { return true; } - abstract performAction(_e: Event): void; + /** See IFocusableTree.getRootFocusableNode. */ + getRootFocusableNode(): IFocusableNode { + return this; + } + + /** See IFocusableTree.getRestoredFocusableNode. */ + getRestoredFocusableNode(): IFocusableNode | null { + return this; + } + + /** See IFocusableTree.getNestedTrees. */ + getNestedTrees(): IFocusableTree[] { + return []; + } + + /** See IFocusableTree.lookUpFocusableNode. */ + lookUpFocusableNode(): IFocusableNode | null { + return null; + } + + /** See IFocusableTree.onTreeFocus. */ + onTreeFocus(): void {} + + /** See IFocusableTree.onTreeBlur. */ + onTreeBlur(): void {} + + /** See IFocusableTree.getNavigator. */ + getNavigator(): WorkspaceControlNavigator { + return this.navigator; + } + + abstract performAction(e: Event): void; dispose() { + if (getFocusManager().isRegistered(this)) { + getFocusManager().unregisterTree(this); + } browserEvents.unbind(this.pointerDownHandler); } } @@ -380,23 +418,26 @@ export class ZoomControls implements IPositionable { ); } - for (const control of [ - this.zoomOutControl, - this.zoomInControl, - this.zoomResetControl, - ]) { - if (!control) continue; - - this.workspace.getComponentManager().addComponent({ - component: control, - weight: ComponentManager.ComponentWeight.ZOOM_CONTROLS_WEIGHT, - capabilities: [ComponentManager.Capability.FOCUSABLE], - }); - } - return this.svgGroup; } + /** + * Returns the individual zoom buttons as focus trees for nesting under the + * workspace. + * + * @internal + */ + getFocusableControls(): IFocusableTree[] { + const controls: IFocusableTree[] = [ + this.zoomOutControl!, + this.zoomInControl!, + ]; + if (this.zoomResetControl) { + controls.push(this.zoomResetControl); + } + return controls; + } + /** Initializes the zoom controls. */ init() { this.workspace.getComponentManager().addComponent({ @@ -413,12 +454,12 @@ export class ZoomControls implements IPositionable { */ dispose() { this.workspace.getComponentManager().removeComponent('zoomControls'); - if (this.svgGroup) { - dom.removeNode(this.svgGroup); - } this.zoomInControl?.dispose(); this.zoomOutControl?.dispose(); this.zoomResetControl?.dispose(); + if (this.svgGroup) { + dom.removeNode(this.svgGroup); + } } /** diff --git a/packages/blockly/tests/mocha/keyboard_navigation_controller_test.js b/packages/blockly/tests/mocha/keyboard_navigation_controller_test.js index 808eff438e7..a64ecf57936 100644 --- a/packages/blockly/tests/mocha/keyboard_navigation_controller_test.js +++ b/packages/blockly/tests/mocha/keyboard_navigation_controller_test.js @@ -10,11 +10,16 @@ import { sharedTestSetup, sharedTestTeardown, } from './test_helpers/setup_teardown.js'; +import {createKeyDownEvent} from './test_helpers/user_input.js'; suite('Keyboard Navigation Controller', function () { setup(function () { sharedTestSetup.call(this); - this.workspace = Blockly.inject('blocklyDiv', DEFAULT_INJECT_OPTIONS); + this.workspace = Blockly.inject('blocklyDiv', { + ...DEFAULT_INJECT_OPTIONS, + trashcan: true, + zoom: {controls: true}, + }); Blockly.keyboardNavigationController.setIsActive(false); }); @@ -40,4 +45,31 @@ suite('Keyboard Navigation Controller', function () { .parentElement.classList.contains('blocklyKeyboardNavigation'), ); }); + + test('arrow key from a workspace control does not move focus onto blocks', function () { + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'simple_test_block', + 'message0': 'simple test block', + 'output': null, + }, + ]); + const block = this.workspace.newBlock('simple_test_block'); + block.initSvg(); + block.render(); + + const focusManager = Blockly.getFocusManager(); + const controls = [ + this.workspace.trashcan, + this.workspace.zoomControls_.getFocusableControls()[0], + ]; + + for (const control of controls) { + focusManager.focusNode(control); + this.workspace + .getInjectionDiv() + .dispatchEvent(createKeyDownEvent(Blockly.utils.KeyCodes.DOWN)); + assert.strictEqual(focusManager.getFocusedNode(), control); + } + }); }); diff --git a/packages/blockly/tests/mocha/workspace_svg_test.js b/packages/blockly/tests/mocha/workspace_svg_test.js index 6fe2edbd7a7..c3e3a35d7da 100644 --- a/packages/blockly/tests/mocha/workspace_svg_test.js +++ b/packages/blockly/tests/mocha/workspace_svg_test.js @@ -27,6 +27,8 @@ suite('WorkspaceSvg', function () { this.workspace = Blockly.inject('blocklyDiv', { ...DEFAULT_INJECT_OPTIONS, toolbox: toolbox, + trashcan: true, + zoom: {controls: true}, }); Blockly.defineBlocksWithJsonArray([ { @@ -191,7 +193,7 @@ suite('WorkspaceSvg', function () { const mutatorWorkspace = icon.getWorkspace(); const nestedTrees = this.workspace.getNestedTrees(); - assert.sameMembers(nestedTrees, [mutatorWorkspace]); + assert.include(nestedTrees, mutatorWorkspace); }); test('includes flyouts in nested trees', async function () { @@ -201,9 +203,7 @@ suite('WorkspaceSvg', function () { const nestedTrees = this.workspace.getNestedTrees(); assert.isNotNull(this.workspace.getFlyout()); - assert.sameMembers(nestedTrees, [ - this.workspace.getFlyout().getWorkspace(), - ]); + assert.include(nestedTrees, this.workspace.getFlyout().getWorkspace()); }); }); @@ -1000,4 +1000,54 @@ suite('WorkspaceSvg', function () { suite('Workspace Base class', function () { testAWorkspace(); }); + + test('focusing the workspace after trashcan restores the previously focused block', function () { + const block = this.workspace.newBlock('simple_test_block'); + block.initSvg(); + block.render(); + const trashcan = this.workspace.trashcan; + const focusManager = Blockly.getFocusManager(); + + focusManager.focusNode(block); + assert.strictEqual(focusManager.getFocusedNode(), block); + + focusManager.focusNode(trashcan); + assert.strictEqual(focusManager.getFocusedNode(), trashcan); + + focusManager.focusTree(this.workspace); + assert.strictEqual(focusManager.getFocusedNode(), block); + }); + + test('workspace root remains tabbable while a control is focused', function () { + const trashcan = this.workspace.trashcan; + const focusManager = Blockly.getFocusManager(); + + focusManager.focusTree(this.workspace); + assert.strictEqual(this.workspace.getFocusableElement().tabIndex, -1); + + focusManager.focusNode(trashcan); + assert.strictEqual(this.workspace.getFocusableElement().tabIndex, 0); + }); + + test('canvas precedes trashcan which precedes zoom controls in DOM order', function () { + // Toolbox/workspace tab order is covered in toolbox_test.js + // This checks the order of the controls inside the workspace. + const children = Array.from(this.workspace.getSvgGroup().children); + const canvas = this.workspace.getCanvas(); + const trashEl = this.workspace.trashcan.getFocusableElement(); + const zoomEl = this.workspace.zoomControls_ + .getFocusableControls()[0] + .getFocusableElement().parentNode; + + const canvasIndex = children.indexOf(canvas); + const trashIndex = children.indexOf(trashEl); + const zoomIndex = children.indexOf(zoomEl); + + assert.isAtLeast(canvasIndex, 0); + assert.isAtLeast(trashIndex, 0); + assert.isAtLeast(zoomIndex, 0); + + assert.isBelow(canvasIndex, trashIndex); + assert.isBelow(trashIndex, zoomIndex); + }); });