diff --git a/.github/workflows/keyboard_plugin_test.yml b/.github/workflows/keyboard_plugin_test.yml index efb4494e73d..a064d35ad62 100644 --- a/.github/workflows/keyboard_plugin_test.yml +++ b/.github/workflows/keyboard_plugin_test.yml @@ -7,7 +7,7 @@ on: pull_request: push: branches: - - develop + - add-screen-reader-support-experimental permissions: contents: read @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v5 with: repository: 'google/blockly-keyboard-experimentation' - ref: 'main' + ref: 'add-screen-reader-support-experimental' path: blockly-keyboard-experimentation - name: Use Node.js 20.x diff --git a/blocks/math.ts b/blocks/math.ts index e5aef5fbb6e..9ac84fc0c68 100644 --- a/blocks/math.ts +++ b/blocks/math.ts @@ -32,6 +32,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([ 'type': 'field_number', 'name': 'NUM', 'value': 0, + 'ariaTypeName': 'Number', }, ], 'output': 'Number', @@ -54,12 +55,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([ { 'type': 'field_dropdown', 'name': 'OP', + 'ariaTypeName': 'Arithmetic operation', 'options': [ - ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD'], - ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS'], - ['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY'], - ['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE'], - ['%{BKY_MATH_POWER_SYMBOL}', 'POWER'], + ['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'], + ['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'], + ['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY', 'Times'], + ['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE', 'Divided by'], + ['%{BKY_MATH_POWER_SYMBOL}', 'POWER', 'To the power of'], ], }, { diff --git a/core/block.ts b/core/block.ts index af44facda5d..faa0ae324eb 100644 --- a/core/block.ts +++ b/core/block.ts @@ -962,16 +962,43 @@ export class Block { } /** - * @returns True if this block is a value block with a single editable field. + * @returns True if this block is a value block with a full block field. + * @param mustBeFullBlock Whether the evaluated field must be 'full-block'. + * @param mustBeEditable Whether the evaluated field must be editable. * @internal */ - isSimpleReporter(): boolean { - if (!this.outputConnection) return false; + isSimpleReporter( + mustBeFullBlock: boolean = false, + mustBeEditable: boolean = false, + ): boolean { + return ( + this.getSingletonFullBlockField(mustBeFullBlock, mustBeEditable) !== null + ); + } - for (const input of this.inputList) { - if (input.connection || input.fieldRow.length > 1) return false; - } - return true; + /** + * Determines and returns the only field of this block, or null if there isn't + * one and this block can't be considered a simple reporter. Null will also be + * returned if the singleton block doesn't match additional criteria, if set, + * such as being full-block or editable. + * + * @param mustBeFullBlock Whether the returned field must be 'full-block'. + * @param mustBeEditable Whether the returned field must be editable. + * @returns The only full-block, maybe editable field of this block, or null. + * @internal + */ + getSingletonFullBlockField( + mustBeFullBlock: boolean, + mustBeEditable: boolean, + ): Field | null { + if (!this.outputConnection) return null; + for (const input of this.inputList) if (input.connection) return null; + const matchingFields = Array.from(this.getFields()).filter((field) => { + if (mustBeFullBlock && !field.isFullBlockField()) return false; + if (mustBeEditable && !field.isCurrentlyEditable()) return false; + return true; + }); + return matchingFields.length === 1 ? matchingFields[0] : null; } /** diff --git a/core/block_svg.ts b/core/block_svg.ts index b3fdeb2d6b6..ec39e681445 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -33,12 +33,14 @@ import {BlockDragStrategy} from './dragging/block_drag_strategy.js'; import type {BlockMove} from './events/events_block_move.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {FieldImage} from './field_image.js'; import {FieldLabel} from './field_label.js'; import {getFocusManager} from './focus_manager.js'; import {IconType} from './icons/icon_types.js'; import {MutatorIcon} from './icons/mutator_icon.js'; import {WarningIcon} from './icons/warning_icon.js'; import type {Input} from './inputs/input.js'; +import {inputTypes} from './inputs/input_types.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {ICopyable} from './interfaces/i_copyable.js'; @@ -54,8 +56,10 @@ import {RenderedConnection} from './rendered_connection.js'; import type {IPathObject} from './renderers/common/i_path_object.js'; import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; +import type {ToolboxCategory} from './toolbox/category.js'; import * as Tooltip from './tooltip.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; @@ -168,6 +172,8 @@ export class BlockSvg /** Whether this block is currently being dragged. */ private dragging = false; + public currentConnectionCandidate: RenderedConnection | null = null; + /** * The location of the top left of this block (in workspace coordinates) * relative to either its parent block, or the workspace origin if it has no @@ -215,7 +221,124 @@ export class BlockSvg // The page-wide unique ID of this Block used for focusing. svgPath.id = idGenerator.getNextUniqueId(); + svgPath.tabIndex = -1; + this.currentConnectionCandidate = null; + this.doInit_(); + this.computeAriaRole(); + } + + /** + * Updates the ARIA label of this block to reflect its current configuration. + * + * @internal + */ + recomputeAriaLabel() { + if (this.isSimpleReporter(true, true)) return; + + aria.setState( + this.getFocusableElement(), + aria.State.LABEL, + !this.isInFlyout + ? this.computeAriaLabel() + : this.computeAriaLabelForFlyoutBlock(), + ); + } + + private computeAriaLabelForFlyoutBlock(): string { + return `${this.computeAriaLabel(true)}, block`; + } + + computeAriaLabel( + verbose: boolean = false, + minimal: boolean = false, + currentBlock: this | undefined = undefined, + ): string { + const labelComponents = []; + + if (!this.workspace.isFlyout && this.getRootBlock() === this) { + labelComponents.push('Begin stack'); + } + + const parentInput = ( + this.previousConnection ?? this.outputConnection + )?.targetConnection?.getParentInput(); + if ( + parentInput && + parentInput.type === inputTypes.STATEMENT && + // The first statement input is redundant with the parent block's label. + parentInput !== + parentInput + .getSourceBlock() + .inputList.find((input) => input.type === inputTypes.STATEMENT) + ) { + labelComponents.push(`Begin ${parentInput.getFieldRowLabel()}`); + } else if ( + parentInput && + parentInput.type === inputTypes.VALUE && + this.getParent()?.statementInputCount + ) { + labelComponents.push(`${parentInput.getFieldRowLabel()}`); + } + + const {commaSeparatedSummary, inputCount} = buildBlockSummary( + this, + verbose, + currentBlock, + ); + labelComponents.push(commaSeparatedSummary); + + if (!minimal) { + if (!this.isEnabled()) { + labelComponents.push('disabled'); + } + if (this.isCollapsed()) { + labelComponents.push('collapsed'); + } + if (this.isShadow()) { + labelComponents.push('replaceable'); + } + + if (inputCount > 1) { + labelComponents.push('has inputs'); + } else if (inputCount === 1) { + labelComponents.push('has input'); + } + } + + return labelComponents.join(', '); + } + + private computeAriaRole() { + if (this.workspace.isFlyout) { + aria.setRole(this.pathObject.svgPath, aria.Role.LISTITEM); + } else { + const roleDescription = this.getAriaRoleDescription(); + aria.setState( + this.pathObject.svgPath, + aria.State.ROLEDESCRIPTION, + roleDescription, + ); + aria.setRole(this.pathObject.svgPath, aria.Role.FIGURE); + } + } + + /** + * Returns the ARIA role description for this block. + * + * Block definitions may override this method via a mixin to customize + * their role description. + * + * @returns The ARIA roledescription for this block. + */ + protected getAriaRoleDescription() { + if (this.outputConnection) { + return 'value block'; + } else if (this.statementInputCount) { + return 'container block'; + } else { + return 'statement block'; + } } /** @@ -242,6 +365,7 @@ export class BlockSvg this.workspace.getCanvas().appendChild(svg); } this.initialized = true; + this.recomputeAriaLabel(); } /** @@ -266,12 +390,14 @@ export class BlockSvg select() { this.addSelect(); common.fireSelectedEvent(this); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); } /** Unselects this block. Unhighlights the block visually. */ unselect() { this.removeSelect(); common.fireSelectedEvent(null); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); } /** @@ -342,6 +468,8 @@ export class BlockSvg } this.applyColour(); + + this.workspace.recomputeAriaTree(); } /** @@ -572,6 +700,7 @@ export class BlockSvg this.removeInput(collapsedInputName); dom.removeClass(this.svgGroup, 'blocklyCollapsed'); this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID); + this.recomputeAriaLabel(); return; } @@ -593,6 +722,8 @@ export class BlockSvg this.getInput(collapsedInputName) || this.appendDummyInput(collapsedInputName); input.appendField(new FieldLabel(text), collapsedFieldName); + + this.recomputeAriaLabel(); } /** @@ -1008,6 +1139,8 @@ export class BlockSvg for (const child of this.getChildren(false)) { child.updateDisabled(); } + + this.recomputeAriaLabel(); } /** @@ -1775,22 +1908,37 @@ export class BlockSvg /** Starts a drag on the block. */ startDrag(e?: PointerEvent): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.startDrag(e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false, location); } /** Drags the block to the given location. */ drag(newLoc: Coordinate, e?: PointerEvent): void { + const prevLocation = this.getRelativeToSurfaceXY(); this.dragStrategy.drag(newLoc, e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false, prevLocation, newLoc); } /** Ends the drag on the block. */ endDrag(e?: PointerEvent): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.endDrag(e); + this.currentConnectionCandidate = null; + this.announceDynamicAriaState(false, false, location); } /** Moves the block back to where it was at the start of a drag. */ revertDrag(): void { + const location = this.getRelativeToSurfaceXY(); this.dragStrategy.revertDrag(); + this.announceDynamicAriaState(false, true, location); } /** @@ -1833,7 +1981,8 @@ export class BlockSvg /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { - return this.pathObject.svgPath; + const singletonField = this.getSingletonFullBlockField(true, true); + return singletonField?.getFocusableElement() ?? this.pathObject.svgPath; } /** See IFocusableNode.getFocusableTree. */ @@ -1843,6 +1992,7 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { + this.recomputeAriaLabel(); this.select(); this.workspace.scrollBoundsIntoView( this.getBoundingRectangleWithoutChildren(), @@ -1858,4 +2008,324 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Returns how deeply nested this block is in parent C-shaped blocks. + * + * @internal + * @returns The nesting level of this block, starting at 0 for root blocks. + */ + getNestingLevel(): number { + // Don't consider value blocks to be nested. + if (this.outputConnection) { + return this.getParent()?.getNestingLevel() ?? 0; + } + + const surroundParent = this.getSurroundParent(); + return surroundParent ? surroundParent.getNestingLevel() + 1 : 0; + } + + /** + * Announces the current dynamic state of the specified block, if any. + * + * An example of dynamic state is whether the block is currently being moved, + * and in what way. These states aren't represented through ARIA directly, so + * they need to be determined and announced using an ARIA live region + * (see aria.announceDynamicAriaState). + * + * @param isMoving Whether the specified block is currently being moved. + * @param isCanceled Whether the previous movement operation has been canceled. + * @param prevLoc Either the current location of the block, or its previous + * location if it's been moved (and a newLoc is provided). + * @param newLoc The new location the block is moving to (if unconstrained). + */ + private announceDynamicAriaState( + isMoving: boolean, + isCanceled: boolean, + prevLoc: Coordinate, + newLoc?: Coordinate, + ) { + if (isCanceled) { + aria.announceDynamicAriaState('Canceled movement'); + return; + } + if (!isMoving) return; + if (this.currentConnectionCandidate) { + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + const surroundParent = this.currentConnectionCandidate.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) { + announcementContext.push('to', 'input'); + } else { + announcementContext.push('to', 'child'); + } + if (surroundParent) { + announcementContext.push('of', surroundParent.computeAriaLabel()); + } + + // If the block is currently being moved, announce the new block label so that the user understands where it is now. + // TODO: Figure out how much recomputeAriaTreeItemDetailsRecursively needs to anticipate position if it won't be reannounced, and how much of that context should be included in the liveannouncement. + aria.announceDynamicAriaState(announcementContext.join(' ')); + } else if (newLoc) { + // The block is being freely dragged. + const direction = this.diff(prevLoc, newLoc); + if (direction === CoordinateShift.MOVE_NORTH) { + aria.announceDynamicAriaState('Moved block up.'); + } else if (direction === CoordinateShift.MOVE_EAST) { + aria.announceDynamicAriaState('Moved block right.'); + } else if (direction === CoordinateShift.MOVE_SOUTH) { + aria.announceDynamicAriaState('Moved block down.'); + } else if (direction === CoordinateShift.MOVE_WEST) { + aria.announceDynamicAriaState('Moved block left.'); + } else if (direction === CoordinateShift.MOVE_NORTHEAST) { + aria.announceDynamicAriaState('Moved block up and right.'); + } else if (direction === CoordinateShift.MOVE_SOUTHEAST) { + aria.announceDynamicAriaState('Moved block down and right.'); + } else if (direction === CoordinateShift.MOVE_SOUTHWEST) { + aria.announceDynamicAriaState('Moved block down and left.'); + } else if (direction === CoordinateShift.MOVE_NORTHWEST) { + aria.announceDynamicAriaState('Moved block up and left.'); + } + // Else don't announce anything because the block didn't move. + } + } + + private diff(fromCoord: Coordinate, toCoord: Coordinate): CoordinateShift { + const xDiff = this.diffAxis(fromCoord.x, toCoord.x); + const yDiff = this.diffAxis(fromCoord.y, toCoord.y); + if (xDiff === AxisShift.SAME && yDiff == AxisShift.SAME) { + return CoordinateShift.STAY_STILL; + } + if (xDiff === AxisShift.SAME) { + // Move vertically. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTH; + } else { + return CoordinateShift.MOVE_SOUTH; + } + } else if (yDiff === AxisShift.SAME) { + // Move horizontally. + if (xDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_WEST; + } else { + return CoordinateShift.MOVE_EAST; + } + } else { + // Move diagonally. + if (xDiff === AxisShift.SMALLER) { + // Move left. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTHWEST; + } else { + return CoordinateShift.MOVE_SOUTHWEST; + } + } else { + // Move right. + if (yDiff === AxisShift.SMALLER) { + return CoordinateShift.MOVE_NORTHEAST; + } else { + return CoordinateShift.MOVE_SOUTHEAST; + } + } + } + } + + private diffAxis(fromX: number, toX: number): AxisShift { + if (this.isEqual(fromX, toX)) { + return AxisShift.SAME; + } else if (toX > fromX) { + return AxisShift.LARGER; + } else { + return AxisShift.SMALLER; + } + } + + private isEqual(x: number, y: number): boolean { + return Math.abs(x - y) < 1e-10; + } +} + +enum CoordinateShift { + MOVE_NORTH, + MOVE_EAST, + MOVE_SOUTH, + MOVE_WEST, + MOVE_NORTHEAST, + MOVE_SOUTHEAST, + MOVE_SOUTHWEST, + MOVE_NORTHWEST, + STAY_STILL, +} + +enum AxisShift { + SMALLER, + SAME, + LARGER, +} + +interface BlockSummary { + blockSummary: string; + commaSeparatedSummary: string; + inputCount: number; +} + +function buildBlockSummary( + block: BlockSvg, + verbose: boolean, + currentBlock?: BlockSvg, +): BlockSummary { + let inputCount = 0; + + // Produce structured segments + // For example, the block: + // "create list with item foo repeated 5 times" + // becomes: + // LABEL("create list with item"), + // INPUT("foo"), + // LABEL("repeated") + // INPUT("5"), + // LABEL("times") + type SummarySegment = + | {kind: 'label'; text: string} + | {kind: 'input'; text: string}; + + function recursiveInputSummary( + block: BlockSvg, + isNestedInput: boolean = false, + ): SummarySegment[] { + return block.inputList.flatMap((input) => { + const fields: SummarySegment[] = input.fieldRow + .filter((field) => { + if (!field.isVisible()) return false; + if (field instanceof FieldImage && field.isClickable()) { + return false; + } + return true; + }) + .map((field) => { + const text = field.computeAriaLabel(verbose); + // If the block is a full block field, we only want to know if it's an + // editable field if we're not directly on it. + if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) { + inputCount++; + return {kind: 'input', text}; + } + + return {kind: 'label', text}; + }); + + if ( + input.isVisible() && + input.connection && + input.connection.type === ConnectionType.INPUT_VALUE + ) { + if (!isNestedInput) { + inputCount++; + } + + const targetBlock = input.connection.targetBlock(); + if (targetBlock) { + const nestedSegments = recursiveInputSummary( + targetBlock as BlockSvg, + true, + ); + + if (targetBlock === currentBlock) { + nestedSegments.unshift({kind: 'label', text: 'Current block: '}); + } + + if (!isNestedInput) { + // treat the whole nested summary as a single input segment + const nestedText = nestedSegments.map((s) => s.text).join(' '); + return [...fields, {kind: 'input', text: nestedText}]; + } + + return [...fields, ...nestedSegments]; + } + } + + return fields; + }); + } + + const segments = recursiveInputSummary(block); + + const blockSummary = segments.map((s) => s.text).join(' '); + + const spokenParts: string[] = []; + let labelRun: string[] = []; + + // create runs of labels, flush when hitting an input + const flushLabels = () => { + if (!labelRun.length) return; + spokenParts.push(labelRun.join(' ')); + labelRun = []; + }; + + for (const seg of segments) { + if (seg.kind === 'label') { + labelRun.push(seg.text); + } else { + flushLabels(); + spokenParts.push(seg.text); + } + } + flushLabels(); + + if (verbose) { + const toolbox = block.workspace.getToolbox(); + let parentCategory: ToolboxCategory | undefined = undefined; + let colourMatchingCategory: ToolboxCategory | undefined = undefined; + if ( + toolbox && + 'getToolboxItems' in toolbox && + typeof toolbox.getToolboxItems === 'function' + ) { + for (const category of toolbox.getToolboxItems()) { + if ( + !( + 'getColour' in category && + typeof category.getColour === 'function' && + 'getContents' in category && + typeof category.getContents === 'function' + ) + ) { + continue; + } + if (category.getColour() === block.getColour()) { + colourMatchingCategory = category; + } + const contents = category.getContents(); + if (!Array.isArray(contents)) break; + for (const item of contents) { + if ( + item.kind.toLowerCase() === 'block' && + 'type' in item && + item.type === block.type + ) { + parentCategory = category; + break; + } + } + if (parentCategory) break; + } + } + if (parentCategory || colourMatchingCategory) { + spokenParts.push( + `${(parentCategory ?? colourMatchingCategory)?.getName()} category`, + ); + } + } + + // comma-separate label runs and inputs + const commaSeparatedSummary = spokenParts.join(', '); + + return { + blockSummary, + commaSeparatedSummary, + inputCount, + }; } diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index 742d300adf1..2967b418157 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import {ISelectable} from '../interfaces/i_selectable.js'; import {ContainerRegion} from '../metrics_manager.js'; import {Scrollbar} from '../scrollbar.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -142,6 +143,9 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); + aria.setRole(this.focusableElement, aria.Role.GROUP); + const label = this.getAriaLabel(); + if (label) aria.setState(this.focusableElement, aria.State.LABEL, label); browserEvents.conditionalBind( this.background, @@ -164,6 +168,13 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.disposed = true; } + /** + * @returns The ARIA label to use for this bubble, or null if none should be used. + */ + protected getAriaLabel(): string | null { + return null; + } + /** * Set the location the tail of this bubble points to. * diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts index 194cb41f35d..303452e7a33 100644 --- a/core/bubbles/mini_workspace_bubble.ts +++ b/core/bubbles/mini_workspace_bubble.ts @@ -89,6 +89,10 @@ export class MiniWorkspaceBubble extends Bubble { this.updateBubbleSize(); } + protected override getAriaLabel(): string | null { + return 'Mutator Bubble'; + } + dispose() { this.miniWorkspace.dispose(); super.dispose(); diff --git a/core/bubbles/text_bubble.ts b/core/bubbles/text_bubble.ts index 99299fa50e8..a67f2d6cf06 100644 --- a/core/bubbles/text_bubble.ts +++ b/core/bubbles/text_bubble.ts @@ -30,6 +30,10 @@ export class TextBubble extends Bubble { dom.addClass(this.svgRoot, 'blocklyTextBubble'); } + protected override getAriaLabel(): string | null { + return 'Warning Bubble'; + } + /** @returns the current text of this text bubble. */ getText(): string { return this.text; diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 0bad5fabce6..4420d05be0a 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -87,6 +87,10 @@ export class TextInputBubble extends Bubble { this.setSize(this.DEFAULT_SIZE, true); } + protected override getAriaLabel(): string | null { + return 'Comment Bubble'; + } + /** @returns the text of this bubble. */ getText(): string { return this.editor.getText(); diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts index 304e2af8125..ee4133aba2f 100644 --- a/core/comments/collapse_comment_bar_button.ts +++ b/core/comments/collapse_comment_bar_button.ts @@ -5,7 +5,9 @@ */ import * as browserEvents from '../browser_events.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -56,6 +58,7 @@ export class CollapseCommentBarButton extends CommentBarButton { }, this.container, ); + this.initAria(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -71,6 +74,11 @@ export class CollapseCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, Msg['COLLAPSE_COMMENT']); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts index be130b0e335..d15366ea3a4 100644 --- a/core/comments/comment_bar_button.ts +++ b/core/comments/comment_bar_button.ts @@ -46,6 +46,8 @@ export abstract class CommentBarButton implements IFocusableNode { return this.commentView; } + abstract initAria(): void; + /** Adjusts the position of this button within its parent container. */ abstract reposition(): void; diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index b4c741ba1ad..a5ce260a985 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -9,6 +9,7 @@ import {getFocusManager} from '../focus_manager.js'; import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; @@ -56,6 +57,7 @@ export class CommentEditor implements IFocusableNode { ) as HTMLTextAreaElement; this.textArea.setAttribute('tabindex', '-1'); this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); + aria.setRole(this.textArea, aria.Role.TEXTBOX); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index b1cd628f8dd..a63971122fd 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -10,6 +10,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as drag from '../utils/drag.js'; @@ -123,6 +124,12 @@ export class CommentView implements IRenderedElement { this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); + aria.setRole(this.svgRoot, aria.Role.BUTTON); + if (this.commentEditor.id) { + aria.setState(this.svgRoot, aria.State.LABELLEDBY, this.commentEditor.id); + } + aria.setState(this.svgRoot, aria.State.ROLEDESCRIPTION, 'Comment'); + // TODO: Remove this comment before merging. // I think we want comments to exist on the same layer as blocks. workspace.getLayerManager()?.append(this, layers.BLOCK); diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts index c61db9b9cd2..f6096d175db 100644 --- a/core/comments/delete_comment_bar_button.ts +++ b/core/comments/delete_comment_bar_button.ts @@ -6,7 +6,9 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -56,6 +58,7 @@ export class DeleteCommentBarButton extends CommentBarButton { }, container, ); + this.initAria(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -71,6 +74,11 @@ export class DeleteCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, Msg['REMOVE_COMMENT']); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/contextmenu_items.ts b/core/contextmenu_items.ts index 001a3c58e25..764758322b7 100644 --- a/core/contextmenu_items.ts +++ b/core/contextmenu_items.ts @@ -26,12 +26,6 @@ import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import type {WorkspaceSvg} from './workspace_svg.js'; -function isFullBlockField(block?: BlockSvg) { - if (!block || !block.isSimpleReporter()) return false; - const firstField = block.getFields().next().value; - return firstField?.isFullBlockField(); -} - /** * Option to undo previous action. */ @@ -377,7 +371,7 @@ export function registerComment() { // Either block already has a comment so let us remove it, // or the block isn't just one full-block field block, which // shouldn't be allowed to have comments as there's no way to read them. - (block.hasIcon(CommentIcon.TYPE) || !isFullBlockField(block)) + (block.hasIcon(CommentIcon.TYPE) || !block.isSimpleReporter(true)) ) { return 'enabled'; } diff --git a/core/css.ts b/core/css.ts index 503b6362ba2..ae7bbee06d6 100644 --- a/core/css.ts +++ b/core/css.ts @@ -508,4 +508,12 @@ input[type=number] { ) { outline: none; } + +.hiddenForAria { + position: absolute; + left: -9999px; + width: 1px; + height: px; + overflow: hidden; +} `; diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 76020f90b5b..620308155e3 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -50,7 +50,7 @@ export class BlockDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; - private connectionCandidate: ConnectionCandidate | null = null; + public connectionCandidate: ConnectionCandidate | null = null; private connectionPreviewer: IConnectionPreviewer | null = null; diff --git a/core/field.ts b/core/field.ts index e025efab709..60b1ea17868 100644 --- a/core/field.ts +++ b/core/field.ts @@ -31,6 +31,7 @@ import {ISerializable} from './interfaces/i_serializable.js'; import type {ConstantProvider} from './renderers/common/constants.js'; import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; +import * as aria from './utils/aria.js'; import type {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; @@ -195,8 +196,7 @@ export abstract class Field */ SERIALIZABLE = false; - /** The unique ID of this field. */ - private id_: string | null = null; + private config: FieldConfig | null = null; /** * @param value The initial value of the field. @@ -250,6 +250,7 @@ export abstract class Field if (config.tooltip) { this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); } + this.config = config; } /** @@ -268,7 +269,69 @@ export abstract class Field `problems with focus: ${block.id}.`, ); } - this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; + } + + /** + * Gets a an ARIA-friendly label representation of this field's type. + * + * @returns An ARIA representation of the field's type or null if it is + * unspecified. + */ + getAriaTypeName(): string | null { + return this.config?.ariaTypeName ?? null; + } + + /** + * Gets a an ARIA-friendly label representation of this field's value. + * + * Note that implementations should generally always override this value to + * ensure a non-null value is returned since the default implementation relies + * on 'getValue' which may return null, and a null return value for this + * function will prompt ARIA label generation to skip the field's value + * entirely when there may be a better contextual placeholder to use, instead, + * specific to the field. + * + * @returns An ARIA representation of the field's value, or null if no value + * is currently defined or known for the field. + */ + getAriaValue(): string | null { + const currentValue = this.getValue(); + return currentValue !== null ? String(currentValue) : null; + } + + /** + * Computes a descriptive ARIA label to represent this field with configurable + * verbosity. + * + * A 'verbose' label includes type information, if available, whereas a + * non-verbose label only contains the field's value. + * + * Note that this will always return the latest representation of the field's + * label which may differ from any previously set ARIA label for the field + * itself. Implementations are largely responsible for ensuring that the + * field's ARIA label is set correctly at relevant moments in the field's + * lifecycle (such as when its value changes). + * + * Finally, it is never guaranteed that implementations use the label returned + * by this method for their actual ARIA label. Some implementations may rely + * on other context to convey information like the field's value. Example: + * checkboxes represent their checked/non-checked status (i.e. value) through + * a separate ARIA property. + * + * It's possible this returns an empty string if the field doesn't supply type + * or value information for certain cases (such as a null value). This will + * lead to the field being potentially COMPLETELY HIDDEN for screen reader + * navigation. + * + * @param verbose Whether to include the field's type information in the + * returned label, if available. + */ + computeAriaLabel(verbose: boolean = false): string { + const components: Array = [this.getAriaValue()]; + if (verbose) { + components.push(this.getAriaTypeName()); + } + return components.filter((item) => item !== null).join(', '); } /** @@ -312,11 +375,8 @@ export abstract class Field // Field has already been initialized once. return; } - const id = this.id_; - if (!id) throw new Error('Expected ID to be defined prior to init.'); - this.fieldGroup_ = dom.createSvgElement(Svg.G, { - 'id': id, - }); + + this.fieldGroup_ = dom.createSvgElement(Svg.G, {}); if (!this.isVisible()) { this.fieldGroup_.style.display = 'none'; } @@ -328,6 +388,11 @@ export abstract class Field this.bindEvents_(); this.initModel(); this.applyColour(); + + const id = this.sourceBlock_?.isSimpleReporter(true, true) + ? idGenerator.getNextUniqueId() + : `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`; + this.fieldGroup_.setAttribute('id', id); } /** @@ -400,6 +465,7 @@ export abstract class Field } this.textContent_ = document.createTextNode(''); this.textElement_.appendChild(this.textContent_); + aria.setState(this.textElement_, aria.State.HIDDEN, true); } /** @@ -1416,7 +1482,10 @@ export abstract class Field * Extra configuration options for the base field. */ export interface FieldConfig { + type: string; + name?: string; tooltip?: string; + ariaTypeName?: string; } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index 55ed42cbf4b..a0a3804535a 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -16,6 +16,7 @@ import './events/events_block_change.js'; import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -111,6 +112,28 @@ export class FieldCheckbox extends Field { const textElement = this.getTextElement(); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + + this.recomputeAria(); + } + + override getAriaValue(): string { + return this.value_ ? 'checked' : 'not checked'; + } + + private recomputeAria() { + const element = this.getFocusableElement(); + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + if (!isInFlyout) { + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + this.getAriaTypeName() ?? 'Checkbox', + ); + aria.setState(element, aria.State.CHECKED, !!this.value_); + } else { + aria.setState(element, aria.State.HIDDEN, true); + } } override render_() { @@ -138,6 +161,7 @@ export class FieldCheckbox extends Field { /** Toggle the state of the checkbox on click. */ protected override showEditor_() { this.setValue(!this.value_); + this.recomputeAria(); } /** diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 3be5c94c3e3..be338108210 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -28,6 +28,7 @@ import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; +import * as idGenerator from './utils/idgenerator.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; import * as utilsString from './utils/string.js'; @@ -197,6 +198,31 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyField'); dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + + this.recomputeAria(); + } + + override getAriaValue(): string { + return this.computeLabelForOption(this.selectedOption); + } + + protected recomputeAria() { + if (!this.fieldGroup_) return; // There's no element to set currently. + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + const element = this.getFocusableElement(); + if (!isInFlyout) { + aria.setRole(element, aria.Role.COMBOBOX); + aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX); + aria.setState(element, aria.State.EXPANDED, !!this.menu_); + if (this.menu_) { + aria.setState(element, aria.State.CONTROLS, this.menu_.id); + } else { + aria.clearState(element, aria.State.CONTROLS); + } + aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); + } else { + aria.setState(element, aria.State.HIDDEN, true); + } } /** @@ -327,7 +353,11 @@ export class FieldDropdown extends Field { } return label; })(); - const menuItem = new MenuItem(content, value); + const menuItem = new MenuItem( + content, + value, + this.computeLabelForOption(option), + ); menuItem.setRole(aria.Role.OPTION); menuItem.setRightToLeft(block.RTL); menuItem.setCheckable(true); @@ -338,6 +368,24 @@ export class FieldDropdown extends Field { } menuItem.onAction(this.handleMenuActionEvent, this); } + + this.recomputeAria(); + } + + private computeLabelForOption(option: MenuOption): string { + if (option === FieldDropdown.SEPARATOR) { + return ''; // Separators don't need labels. + } else if (!Array.isArray(option)) { + return ''; // Certain dynamic options aren't iterable. TODO: Figure this out. It breaks when opening certain test toolbox categories in the advanced playground. + } + const [label, value, optionalAriaLabel] = option; + const altText = isImageProperties(label) ? label.alt : null; + return ( + altText ?? + optionalAriaLabel ?? + this.computeHumanReadableText(option) ?? + String(value) + ); } /** @@ -350,6 +398,7 @@ export class FieldDropdown extends Field { this.menu_ = null; this.selectedMenuItem = null; this.applyColour(); + this.recomputeAria(); } /** @@ -372,6 +421,11 @@ export class FieldDropdown extends Field { this.setValue(menuItem.getValue()); } + override setValue(newValue: AnyDuringMigration, fireChangeEvent = true) { + super.setValue(newValue, fireChangeEvent); + this.recomputeAria(); + } + /** * @returns True if the option list is generated by a function. * Otherwise false. @@ -524,14 +578,11 @@ export class FieldDropdown extends Field { if (!block) { throw new UnattachedFieldError(); } - this.imageElement!.style.display = ''; - this.imageElement!.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - imageJson.src, - ); - this.imageElement!.setAttribute('height', String(imageJson.height)); - this.imageElement!.setAttribute('width', String(imageJson.width)); + const imageElement = this.imageElement!; + imageElement.style.display = ''; + imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', imageJson.src); + imageElement.setAttribute('height', String(imageJson.height)); + imageElement.setAttribute('width', String(imageJson.width)); const imageHeight = Number(imageJson.height); const imageWidth = Number(imageJson.width); @@ -559,15 +610,21 @@ export class FieldDropdown extends Field { let arrowX = 0; if (block.RTL) { const imageX = xPadding + arrowWidth; - this.imageElement!.setAttribute('x', `${imageX}`); + imageElement.setAttribute('x', `${imageX}`); } else { arrowX = imageWidth + arrowWidth; this.getTextElement().setAttribute('text-anchor', 'end'); - this.imageElement!.setAttribute('x', `${xPadding}`); + imageElement.setAttribute('x', `${xPadding}`); } - this.imageElement!.setAttribute('y', String(height / 2 - imageHeight / 2)); + imageElement.setAttribute('y', String(height / 2 - imageHeight / 2)); this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth); + + if (imageElement.id === '') { + imageElement.id = idGenerator.getNextUniqueId(); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.ACTIVEDESCENDANT, imageElement.id); + } } /** Renders the selected option, which must be text. */ @@ -577,6 +634,13 @@ export class FieldDropdown extends Field { const textElement = this.getTextElement(); dom.addClass(textElement, 'blocklyDropdownText'); textElement.setAttribute('text-anchor', 'start'); + // The field's text should be visible to readers since it will be read out + // as static text as part of the combobox (per the ARIA combobox pattern). + if (textElement.id === '') { + textElement.id = idGenerator.getNextUniqueId(); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id); + } // Height and width include the border rect. const hasBorder = !!this.borderRect_; @@ -646,7 +710,11 @@ export class FieldDropdown extends Field { if (!this.selectedOption) { return null; } - const option = this.selectedOption[0]; + return this.computeHumanReadableText(this.selectedOption); + } + + private computeHumanReadableText(menuOption: MenuOption): string | null { + const option = menuOption[0]; if (isImageProperties(option)) { return option.alt; } else if ( @@ -681,7 +749,7 @@ export class FieldDropdown extends Field { throw new Error( 'options are required for the dropdown field. The ' + 'options property must be assigned an array of ' + - '[humanReadableValue, languageNeutralValue] tuples.', + '[humanReadableValue, languageNeutralValue, opt_ariaLabel] tuples.', ); } // `this` might be a subclass of FieldDropdown if that class doesn't @@ -705,9 +773,9 @@ export class FieldDropdown extends Field { return option; } - const [label, value] = option; + const [label, value, opt_ariaLabel] = option; if (typeof label === 'string') { - return [parsing.replaceMessageReferences(label), value]; + return [parsing.replaceMessageReferences(label), value, opt_ariaLabel]; } hasNonTextContent = true; @@ -716,14 +784,14 @@ export class FieldDropdown extends Field { const imageLabel = isImageProperties(label) ? {...label, alt: parsing.replaceMessageReferences(label.alt)} : label; - return [imageLabel, value]; + return [imageLabel, value, opt_ariaLabel]; }); if (hasNonTextContent || options.length < 2) { return {options: trimmedOptions}; } - const stringOptions = trimmedOptions as [string, string][]; + const stringOptions = trimmedOptions as [string, string, string?][]; const stringLabels = stringOptions.map(([label]) => label); const shortest = utilsString.shortestStringLength(stringLabels); @@ -762,13 +830,14 @@ export class FieldDropdown extends Field { * @returns A new array with all of the option text trimmed. */ private applyTrim( - options: [string, string][], + options: [string, string, string?][], prefixLength: number, suffixLength: number, ): MenuOption[] { - return options.map(([text, value]) => [ + return options.map(([text, value, opt_ariaLabel]) => [ text.substring(prefixLength, text.length - suffixLength), value, + opt_ariaLabel, ]); } @@ -860,7 +929,7 @@ export interface ImageProperties { * the language-neutral value. */ export type MenuOption = - | [string | ImageProperties | HTMLElement, string] + | [string | ImageProperties | HTMLElement, string, string?] | 'separator'; /** diff --git a/core/field_image.ts b/core/field_image.ts index 01133c20340..bfa19816eab 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -13,6 +13,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -131,6 +132,10 @@ export class FieldImage extends Field { } } + override getAriaValue(): string { + return this.altText; + } + /** * Create the block UI for this image. */ @@ -154,8 +159,15 @@ export class FieldImage extends Field { dom.addClass(this.fieldGroup_, 'blocklyImageField'); } - if (this.clickHandler) { + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + const element = this.getFocusableElement(); + if (!isInFlyout && this.isClickable()) { this.imageElement.style.cursor = 'pointer'; + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true)); + } else { + // The field isn't navigable unless it's clickable and outside the flyout. + aria.setRole(element, aria.Role.PRESENTATION); } } diff --git a/core/field_input.ts b/core/field_input.ts index 55383a4c1d2..175e80ff55d 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -167,6 +167,8 @@ export abstract class FieldInput extends Field< const block = this.getSourceBlock(); if (!block) throw new UnattachedFieldError(); super.initView(); + if (!this.textElement_) + throw new Error('Initialization failed for FieldInput'); if (this.isFullBlockField()) { this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); @@ -175,6 +177,23 @@ export abstract class FieldInput extends Field< if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + + this.recomputeAriaLabel(); + } + + /** + * Updates the ARIA label for this field. + */ + protected recomputeAriaLabel() { + if (!this.fieldGroup_) return; + const element = this.getFocusableElement(); + const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false; + if (!isInFlyout) { + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, super.computeAriaLabel()); + } else { + aria.setState(element, aria.State.HIDDEN, true); + } } override isFullBlockField(): boolean { @@ -207,6 +226,7 @@ export abstract class FieldInput extends Field< const oldValue = this.value_; // Revert value when the text becomes invalid. this.value_ = this.valueWhenEditorWasOpened_; + this.recomputeAriaLabel(); if ( this.sourceBlock_ && eventUtils.isEnabled() && @@ -238,6 +258,7 @@ export abstract class FieldInput extends Field< this.isDirty_ = true; this.isTextValid_ = true; this.value_ = newValue; + this.recomputeAriaLabel(); } /** diff --git a/core/field_label.ts b/core/field_label.ts index 236154cc7b1..d89e397f9c4 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -14,6 +14,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -77,6 +78,8 @@ export class FieldLabel extends Field { if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); } + + aria.setState(this.getFocusableElement(), aria.State.HIDDEN, true); } /** @@ -111,6 +114,10 @@ export class FieldLabel extends Field { this.class = cssClass; } + override setValue(newValue: any, fireChangeEvent?: boolean): void { + super.setValue(newValue, fireChangeEvent); + } + /** * Construct a FieldLabel from a JSON arg object, * dereferencing any string table references. diff --git a/core/field_number.ts b/core/field_number.ts index 7e36591753e..b1d58e86ee9 100644 --- a/core/field_number.ts +++ b/core/field_number.ts @@ -18,9 +18,13 @@ import { FieldInputValidator, } from './field_input.js'; import * as fieldRegistry from './field_registry.js'; -import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; +// TODO: Figure out how to either design this to be a 'number' input with proper +// 'valuemin' and 'valuemax' ARIA properties, build it so that subtypes can do +// this properly, or consider a separate field type altogether that handles that +// case properly. See: https://github.com/google/blockly/pull/9384#discussion_r2395601092. + /** * Class for an editable number field. */ @@ -296,14 +300,11 @@ export class FieldNumber extends FieldInput { protected override widgetCreate_(): HTMLInputElement { const htmlInput = super.widgetCreate_() as HTMLInputElement; - // Set the accessibility state if (this.min_ > -Infinity) { htmlInput.min = `${this.min_}`; - aria.setState(htmlInput, aria.State.VALUEMIN, this.min_); } if (this.max_ < Infinity) { htmlInput.max = `${this.max_}`; - aria.setState(htmlInput, aria.State.VALUEMAX, this.max_); } return htmlInput; } @@ -313,7 +314,6 @@ export class FieldNumber extends FieldInput { * * @override */ - public override initView() { super.initView(); if (this.fieldGroup_) { diff --git a/core/field_variable.ts b/core/field_variable.ts index aa4fdfe310f..ede3d751488 100644 --- a/core/field_variable.ts +++ b/core/field_variable.ts @@ -596,22 +596,28 @@ export class FieldVariable extends FieldDropdown { } variableModelList.sort(Variables.compareByName); - const options: [string, string][] = []; + const options: [string, string, string?][] = []; for (let i = 0; i < variableModelList.length; i++) { // Set the UUID as the internal representation of the variable. options[i] = [ variableModelList[i].getName(), variableModelList[i].getId(), + Msg['ARIA_LABEL_FOR_VARIABLE_NAME'].replace( + '%1', + variableModelList[i].getName(), + ), ]; } options.push([ Msg['RENAME_VARIABLE'], internalConstants.RENAME_VARIABLE_ID, + Msg['RENAME_VARIABLE'], ]); if (Msg['DELETE_VARIABLE']) { options.push([ Msg['DELETE_VARIABLE'].replace('%1', name), internalConstants.DELETE_VARIABLE_ID, + Msg['DELETE_VARIABLE'].replace('%1', name), ]); } diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 492d3341762..8d4264f4608 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -33,6 +33,7 @@ import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; import * as blocks from './serialization/blocks.js'; +import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; @@ -309,6 +310,9 @@ export abstract class Flyout 'class': 'blocklyFlyout', }); this.svgGroup_.style.display = 'none'; + // Ignore the svg root in the accessibility tree since is is not focusable. + aria.setRole(this.svgGroup_, aria.Role.PRESENTATION); + this.svgBackground_ = dom.createSvgElement( Svg.PATH, {'class': 'blocklyFlyoutBackground'}, @@ -334,6 +338,9 @@ export abstract class Flyout init(targetWorkspace: WorkspaceSvg) { this.targetWorkspace = targetWorkspace; this.workspace_.targetWorkspace = targetWorkspace; + if (this.targetWorkspace.isMutator) { + aria.setRole(this.workspace_.getFocusableElement(), aria.Role.GENERIC); + } this.workspace_.scrollbar = new ScrollbarPair( this.workspace_, @@ -530,7 +537,9 @@ export abstract class Flyout */ setContents(contents: FlyoutItem[]): void { this.contents = contents; + this.workspace_.recomputeAriaTree(); } + /** * Update the display property of the flyout based whether it thinks it should * be visible and whether its containing workspace is visible. diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 971fc4fee9f..63e3f6a7c8a 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -18,6 +18,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IRenderedElement} from './interfaces/i_rendered_element.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -61,7 +62,13 @@ export class FlyoutButton height = 0; /** The root SVG group for the button or label. */ - private svgGroup: SVGGElement; + private svgContainerGroup: SVGGElement; + + /** The root SVG group for the button's or label's contents. */ + private svgContentGroup: SVGGElement; + + /** The SVG group that can hold focus for this button or label. */ + private svgFocusableGroup: SVGGElement; /** The SVG element with the text of the label or button. */ private svgText: SVGTextElement | null = null; @@ -114,11 +121,28 @@ export class FlyoutButton } this.id = idGenerator.getNextUniqueId(); - this.svgGroup = dom.createSvgElement( + this.svgContainerGroup = dom.createSvgElement( Svg.G, - {'id': this.id, 'class': cssClass}, + {'class': cssClass}, this.workspace.getCanvas(), ); + this.svgContentGroup = dom.createSvgElement( + Svg.G, + {}, + this.svgContainerGroup, + ); + + if (this.isFlyoutLabel) { + aria.setRole(this.svgContainerGroup, aria.Role.LISTITEM); + aria.setRole(this.svgContentGroup, aria.Role.PRESENTATION); + this.svgFocusableGroup = this.svgContainerGroup; + } else { + aria.setRole(this.svgContainerGroup, aria.Role.PRESENTATION); + aria.setRole(this.svgContentGroup, aria.Role.LISTITEM); + this.svgFocusableGroup = this.svgContentGroup; + } + this.svgFocusableGroup.id = this.id; + this.svgFocusableGroup.tabIndex = -1; let shadow; if (!this.isFlyoutLabel) { @@ -132,8 +156,9 @@ export class FlyoutButton 'x': 1, 'y': 1, }, - this.svgGroup!, + this.svgContentGroup, ); + aria.setRole(shadow, aria.Role.PRESENTATION); } // Background rectangle. const rect = dom.createSvgElement( @@ -145,8 +170,9 @@ export class FlyoutButton 'rx': FlyoutButton.BORDER_RADIUS, 'ry': FlyoutButton.BORDER_RADIUS, }, - this.svgGroup!, + this.svgContentGroup, ); + aria.setRole(rect, aria.Role.PRESENTATION); const svgText = dom.createSvgElement( Svg.TEXT, @@ -156,8 +182,9 @@ export class FlyoutButton 'y': 0, 'text-anchor': 'middle', }, - this.svgGroup!, + this.svgContentGroup, ); + aria.setRole(svgText, aria.Role.PRESENTATION); let text = parsing.replaceMessageReferences(this.text); if (this.workspace.RTL) { // Force text to be RTL by adding an RLM. @@ -170,6 +197,15 @@ export class FlyoutButton .getThemeManager() .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); } + if (this.isFlyoutLabel) { + aria.setState(this.svgFocusableGroup, aria.State.LABEL, text); + } else { + aria.setState( + this.svgFocusableGroup, + aria.State.LABEL, + `${text}, button`, + ); + } const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); @@ -206,13 +242,13 @@ export class FlyoutButton this.updateTransform(); this.onMouseDownWrapper = browserEvents.conditionalBind( - this.svgGroup, + this.svgContentGroup, 'pointerdown', this, this.onMouseDown, ); this.onMouseUpWrapper = browserEvents.conditionalBind( - this.svgGroup, + this.svgContentGroup, 'pointerup', this, this.onMouseUp, @@ -222,18 +258,18 @@ export class FlyoutButton createDom(): SVGElement { // No-op, now handled in constructor. Will be removed in followup refactor // PR that updates the flyout classes to use inflaters. - return this.svgGroup; + return this.svgContainerGroup; } /** Correctly position the flyout button and make it visible. */ show() { this.updateTransform(); - this.svgGroup!.setAttribute('display', 'block'); + this.svgContainerGroup!.setAttribute('display', 'block'); } /** Update SVG attributes to match internal state. */ private updateTransform() { - this.svgGroup!.setAttribute( + this.svgContainerGroup!.setAttribute( 'transform', 'translate(' + this.position.x + ',' + this.position.y + ')', ); @@ -319,8 +355,8 @@ export class FlyoutButton dispose() { browserEvents.unbind(this.onMouseDownWrapper); browserEvents.unbind(this.onMouseUpWrapper); - if (this.svgGroup) { - dom.removeNode(this.svgGroup); + if (this.svgContainerGroup) { + dom.removeNode(this.svgContainerGroup); } if (this.svgText) { this.workspace.getThemeManager().unsubscribe(this.svgText); @@ -338,8 +374,8 @@ export class FlyoutButton this.cursorSvg = null; return; } - if (this.svgGroup) { - this.svgGroup.appendChild(cursorSvg); + if (this.svgContainerGroup) { + this.svgContentGroup.appendChild(cursorSvg); this.cursorSvg = cursorSvg; } } @@ -387,12 +423,12 @@ export class FlyoutButton * @returns The root SVG element of this rendered element. */ getSvgRoot() { - return this.svgGroup; + return this.svgContainerGroup; } /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { - return this.svgGroup; + return this.svgFocusableGroup; } /** See IFocusableNode.getFocusableTree. */ diff --git a/core/flyout_item.ts b/core/flyout_item.ts index 26be0ed12e2..1d6c4f31776 100644 --- a/core/flyout_item.ts +++ b/core/flyout_item.ts @@ -8,6 +8,11 @@ export class FlyoutItem { /** * Creates a new FlyoutItem. * + * Note that it's the responsibility of implementations to ensure that element + * is given the ARIA role LISTITEM and respects its expected constraints + * (which includes ensuring that no interactive elements are children of the + * item element--interactive elements themselves should be the LISTITEM). + * * @param element The element that will be displayed in the flyout. * @param type The type of element. Should correspond to the type of the * flyout inflater that created this object. diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 8f5a82c0d15..1b2e47149e4 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import * as renderManagement from '../render_management.js'; import {Coordinate} from '../utils.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; @@ -112,6 +113,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyCommentIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + if (this.svgRoot) { + aria.setState( + this.svgRoot, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); + } } override dispose() { @@ -336,6 +349,8 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { 'comment', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/icon.ts b/core/icons/icon.ts index f5f76603875..259b3dcddbd 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -11,6 +11,7 @@ import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {hasBubble} from '../interfaces/i_has_bubble.js'; import type {IIcon} from '../interfaces/i_icon.js'; import * as tooltip from '../tooltip.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -72,6 +73,9 @@ export abstract class Icon implements IIcon { ); (this.svgRoot as any).tooltip = this; tooltip.bindMouseEvents(this.svgRoot); + + aria.setRole(this.svgRoot, aria.Role.BUTTON); + aria.setState(this.svgRoot, aria.State.LABEL, 'Icon'); } dispose(): void { diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 9055a91ea8f..af32f55df1c 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -16,6 +16,7 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -119,6 +120,16 @@ export class MutatorIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyMutatorIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); } override dispose(): void { @@ -201,6 +212,8 @@ export class MutatorIcon extends Icon implements IHasBubble { 'mutator', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index f24a6a56190..5085769eec9 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -14,6 +14,7 @@ import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Size} from '../utils.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -92,6 +93,16 @@ export class WarningIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); } override dispose() { @@ -196,6 +207,8 @@ export class WarningIcon extends Icon implements IHasBubble { 'warning', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/inject.ts b/core/inject.ts index 4217c515119..f5f04b5c387 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -17,6 +17,7 @@ import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -54,6 +55,9 @@ export function inject( dom.addClass(subContainer, 'blocklyRTL'); } + // Ignore the subcontainer in aria since it is not focusable + aria.setRole(subContainer, aria.Role.PRESENTATION); + containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); @@ -78,6 +82,13 @@ export function inject( common.globalShortcutHandler, ); + // See: https://stackoverflow.com/a/48590836 for a reference. + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + dom.addClass(ariaAnnouncementSpan, 'hiddenForAria'); + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'assertive'); + subContainer.appendChild(ariaAnnouncementSpan); + return workspace; } diff --git a/core/inputs/input.ts b/core/inputs/input.ts index f8783aea35f..5195e5d68f2 100644 --- a/core/inputs/input.ts +++ b/core/inputs/input.ts @@ -193,6 +193,9 @@ export class Input { child.getSvgRoot().style.display = visible ? 'block' : 'none'; } } + if (this.sourceBlock.rendered) { + (this.sourceBlock as BlockSvg).recomputeAriaLabel(); + } return renderList; } @@ -303,6 +306,31 @@ export class Input { } } + /** + * Returns a label for this input's row on its parent block. + * + * Generally this consists of the labels/values of the preceding fields, and + * is intended for accessibility descriptions. + * + * @internal + * @returns A description of this input's row on its parent block. + */ + getFieldRowLabel(): string { + const fieldRowLabel = this.fieldRow + .reduce((label, field) => { + return `${label} ${field.getValue()}`; + }, '') + .trim(); + if (!fieldRowLabel) { + const inputs = this.getSourceBlock().inputList; + const index = inputs.indexOf(this); + if (index > 0) { + return inputs[index - 1].getFieldRowLabel(); + } + } + return fieldRowLabel; + } + /** * Constructs a connection based on the type of this input's source block. * Properly handles constructing headless connections for headless blocks diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 9f56b538455..3449c73f534 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -124,24 +124,48 @@ function getBlockNavigationCandidates( for (const input of block.inputList) { if (!input.isVisible()) continue; + candidates.push(...input.fieldRow); - if (input.connection?.targetBlock()) { - const connectedBlock = input.connection.targetBlock() as BlockSvg; - if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + + const connection = input.connection as RenderedConnection | null; + if (!connection) continue; + + const connectedBlock = connection.targetBlock(); + if (connectedBlock) { + if (connection.type === ConnectionType.NEXT_STATEMENT && !forward) { const lastStackBlock = connectedBlock .lastConnectionInStack(false) ?.getSourceBlock(); if (lastStackBlock) { - candidates.push(lastStackBlock); + // When navigating backward, the last next connection in a stack in a + // statement input is navigable. + candidates.push(lastStackBlock.nextConnection); } } else { + // When navigating forward, a child block connected to a statement + // input is navigable. candidates.push(connectedBlock); } - } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { - candidates.push(input.connection as RenderedConnection); + } else if ( + connection.type === ConnectionType.INPUT_VALUE || + connection.type === ConnectionType.NEXT_STATEMENT + ) { + // Empty input or statement connections are navigable. + candidates.push(connection); } } + if ( + block.nextConnection && + !block.nextConnection.targetBlock() && + (block.lastConnectionInStack(true) !== block.nextConnection || + !!block.getSurroundParent()) + ) { + // The empty next connection on the last block in a stack inside of a + // statement input is navigable. + candidates.push(block.nextConnection); + } + return candidates; } @@ -157,7 +181,8 @@ function getBlockNavigationCandidates( * `delta` relative to the current element's stack when navigating backwards. */ export function navigateStacks(current: ISelectable, delta: number) { - const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + const workspace = current.workspace as WorkspaceSvg; + const stacks: IFocusableNode[] = workspace .getTopBoundedElements(true) .filter((element: IBoundedElement) => isFocusableNode(element)); const currentIndex = stacks.indexOf( @@ -165,18 +190,25 @@ export function navigateStacks(current: ISelectable, delta: number) { ); const targetIndex = currentIndex + delta; let result: IFocusableNode | null = null; + const loop = workspace.getCursor().getNavigationLoops(); if (targetIndex >= 0 && targetIndex < stacks.length) { result = stacks[targetIndex]; - } else if (targetIndex < 0) { + } else if (loop && targetIndex < 0) { result = stacks[stacks.length - 1]; - } else if (targetIndex >= stacks.length) { + } else if (loop && targetIndex >= stacks.length) { result = stacks[0]; + } else { + return null; } // When navigating to a previous block stack, our previous sibling is the last - // block in it. + // block or nested next connection in it. if (delta < 0 && result instanceof BlockSvg) { - return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + result = result.lastConnectionInStack(false)?.getSourceBlock() ?? result; + + if (result instanceof BlockSvg && result.statementInputCount > 0) { + result = getBlockNavigationCandidates(result, false).at(-1) ?? result; + } } return result; diff --git a/core/keyboard_nav/field_navigation_policy.ts b/core/keyboard_nav/field_navigation_policy.ts index f9df406c22c..fb332a1cf85 100644 --- a/core/keyboard_nav/field_navigation_policy.ts +++ b/core/keyboard_nav/field_navigation_policy.ts @@ -65,10 +65,7 @@ export class FieldNavigationPolicy implements INavigationPolicy> { current.canBeFocused() && current.isVisible() && (current.isClickable() || current.isCurrentlyEditable()) && - !( - current.getSourceBlock()?.isSimpleReporter() && - current.isFullBlockField() - ) && + !current.getSourceBlock()?.isSimpleReporter(true, true) && current.getParentInput().isVisible() ); } diff --git a/core/keyboard_nav/flyout_navigation_policy.ts b/core/keyboard_nav/flyout_navigation_policy.ts index 6552c27b499..34ac9f26e26 100644 --- a/core/keyboard_nav/flyout_navigation_policy.ts +++ b/core/keyboard_nav/flyout_navigation_policy.ts @@ -60,6 +60,9 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index++; if (index >= flyoutContents.length) { + if (!this.flyout.getWorkspace().getCursor().getNavigationLoops()) { + return null; + } index = 0; } @@ -83,6 +86,9 @@ export class FlyoutNavigationPolicy implements INavigationPolicy { if (index === -1) return null; index--; if (index < 0) { + if (!this.flyout.getWorkspace().getCursor().getNavigationLoops()) { + return null; + } index = flyoutContents.length - 1; } diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 30770e47d2d..c0d76fa5aae 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -15,12 +15,24 @@ import {BlockSvg} from '../block_svg.js'; import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import {ConnectionType} from '../connection_type.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; +import {RenderedConnection} from '../rendered_connection.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import {Marker} from './marker.js'; +/** + * Representation of the direction of travel within a navigation context. + */ +export enum NavigationDirection { + NEXT, + PREVIOUS, + IN, + OUT, +} + /** * Class for a line cursor. */ @@ -30,6 +42,9 @@ export class LineCursor extends Marker { /** Locations to try moving the cursor to after a deletion. */ private potentialNodes: IFocusableNode[] | null = null; + /** Whether or not navigation loops around when reaching the end. */ + private navigationLoops = true; + /** * @param workspace The workspace this cursor belongs to. */ @@ -51,14 +66,8 @@ export class LineCursor extends Marker { } const newNode = this.getNextNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, + this.getValidationFunction(NavigationDirection.NEXT), + this.getNavigationLoops(), ); if (newNode) { @@ -80,7 +89,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getNextNode(curNode, () => true, true); + const newNode = this.getNextNode( + curNode, + this.getValidationFunction(NavigationDirection.IN), + this.getNavigationLoops(), + ); if (newNode) { this.setCurNode(newNode); @@ -101,14 +114,8 @@ export class LineCursor extends Marker { } const newNode = this.getPreviousNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - (candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock()) || - candidate instanceof RenderedWorkspaceComment - ); - }, - true, + this.getValidationFunction(NavigationDirection.PREVIOUS), + this.getNavigationLoops(), ); if (newNode) { @@ -130,7 +137,11 @@ export class LineCursor extends Marker { return null; } - const newNode = this.getPreviousNode(curNode, () => true, true); + const newNode = this.getPreviousNode( + curNode, + this.getValidationFunction(NavigationDirection.OUT), + this.getNavigationLoops(), + ); if (newNode) { this.setCurNode(newNode); @@ -147,16 +158,15 @@ export class LineCursor extends Marker { atEndOfLine(): boolean { const curNode = this.getCurNode(); if (!curNode) return false; - const inNode = this.getNextNode(curNode, () => true, true); + const inNode = this.getNextNode( + curNode, + this.getValidationFunction(NavigationDirection.IN), + this.getNavigationLoops(), + ); const nextNode = this.getNextNode( curNode, - (candidate: IFocusableNode | null) => { - return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() - ); - }, - true, + this.getValidationFunction(NavigationDirection.NEXT), + this.getNavigationLoops(), ); return inNode === nextNode; @@ -212,11 +222,22 @@ export class LineCursor extends Marker { getNextNode( node: IFocusableNode | null, isValid: (p1: IFocusableNode | null) => boolean, + // TODO: Consider deprecating and removing this argument. loop: boolean, ): IFocusableNode | null { - if (!node || (!loop && this.getLastNode() === node)) return null; + const originalLoop = this.getNavigationLoops(); + this.setNavigationLoops(loop); + + let result: IFocusableNode | null; + if (!node || (!loop && this.getLastNode() === node)) { + result = null; + } else { + result = this.getNextNodeImpl(node, isValid); + } - return this.getNextNodeImpl(node, isValid); + this.setNavigationLoops(originalLoop); + + return result; } /** @@ -266,11 +287,22 @@ export class LineCursor extends Marker { getPreviousNode( node: IFocusableNode | null, isValid: (p1: IFocusableNode | null) => boolean, + // TODO: Consider deprecating and removing this argument. loop: boolean, ): IFocusableNode | null { - if (!node || (!loop && this.getFirstNode() === node)) return null; + const originalLoop = this.getNavigationLoops(); + this.setNavigationLoops(loop); + + let result: IFocusableNode | null; + if (!node || (!loop && this.getFirstNode() === node)) { + result = null; + } else { + result = this.getPreviousNodeImpl(node, isValid); + } - return this.getPreviousNodeImpl(node, isValid); + this.setNavigationLoops(originalLoop); + + return result; } /** @@ -298,6 +330,128 @@ export class LineCursor extends Marker { return this.getRightMostChild(newNode, stopIfFound); } + /** + * Returns a function that will be used to determine whether a candidate for + * navigation is valid. + * + * @param direction The direction in which the user is navigating. + * @returns A function that takes a proposed navigation candidate and returns + * true if navigation should be allowed to proceed to it, or false to find + * a different candidate. + */ + getValidationFunction( + direction: NavigationDirection, + ): (node: IFocusableNode | null) => boolean { + switch (direction) { + case NavigationDirection.IN: + case NavigationDirection.OUT: + return () => true; + case NavigationDirection.NEXT: + case NavigationDirection.PREVIOUS: + return (candidate: IFocusableNode | null) => { + if ( + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment || + (candidate instanceof RenderedConnection && + (candidate.type === ConnectionType.NEXT_STATEMENT || + (candidate.type === ConnectionType.INPUT_VALUE && + candidate.getSourceBlock().statementInputCount && + candidate.getSourceBlock().inputList[0] !== + candidate.getParentInput()))) + ) { + return true; + } + + const currentNode = this.getCurNode(); + if (direction === NavigationDirection.PREVIOUS) { + // Don't visit rightmost/nested blocks in statement blocks when + // navigating to the previous block. + if ( + currentNode instanceof RenderedConnection && + currentNode.type === ConnectionType.NEXT_STATEMENT && + !currentNode.getParentInput() && + candidate !== currentNode.getSourceBlock() + ) { + return false; + } + + // Don't visit the first value/input block in a block with statement + // inputs when navigating to the previous block. This is consistent + // with the behavior when navigating to the next block and avoids + // duplicative screen reader narration. Also don't visit value + // blocks nested in non-statement inputs. + if ( + candidate instanceof BlockSvg && + candidate.outputConnection?.targetConnection + ) { + const parentInput = + candidate.outputConnection.targetConnection.getParentInput(); + if ( + !parentInput?.getSourceBlock().statementInputCount || + parentInput?.getSourceBlock().inputList[0] === parentInput + ) { + return false; + } + } + } + + const currentBlock = this.getSourceBlockFromNode(currentNode); + if ( + candidate instanceof BlockSvg && + currentBlock instanceof BlockSvg + ) { + // If the candidate's parent uses inline inputs, disallow the + // candidate; it follows that it must be on the same row as its + // parent. + if (candidate.outputConnection?.targetBlock()?.getInputsInline()) { + return false; + } + + const candidateParents = this.getParents(candidate); + // If the candidate block is an (in)direct child of the current + // block, disallow it; it cannot be on a different row than the + // current block. + if ( + currentBlock === this.getCurNode() && + candidateParents.has(currentBlock) + ) { + return false; + } + + const currentParents = this.getParents(currentBlock); + + const sharedParents = currentParents.intersection(candidateParents); + // Allow the candidate if it and the current block have no parents + // in common, or if they have a shared parent with external inputs. + const result = + !sharedParents.size || + sharedParents.values().some((block) => !block.getInputsInline()); + return result; + } + + return false; + }; + } + } + + /** + * Returns a set of all of the parent blocks of the given block. + * + * @param block The block to retrieve the parents of. + * @returns A set of the parents of the given block. + */ + private getParents(block: BlockSvg): Set { + const parents = new Set(); + let parent = block.getParent(); + while (parent) { + parents.add(parent); + parent = parent.getParent(); + } + + return parents; + } + /** * Prepare for the deletion of a block by making a list of nodes we * could move the cursor to afterwards and save it to @@ -388,6 +542,17 @@ export class LineCursor extends Marker { * @param newNode The new location of the cursor. */ setCurNode(newNode: IFocusableNode) { + const oldBlock = this.getSourceBlock(); + const newBlock = this.getSourceBlockFromNode(newNode); + if ( + oldBlock && + newBlock && + oldBlock.getNestingLevel() !== newBlock.getNestingLevel() + ) { + newBlock.workspace + .getAudioManager() + .beep(400 + newBlock.getNestingLevel() * 40); + } getFocusManager().focusNode(newNode); } @@ -409,6 +574,24 @@ export class LineCursor extends Marker { const first = this.getFirstNode(); return this.getPreviousNode(first, () => true, true); } + + /** + * Sets whether or not navigation should loop around when reaching the end + * of the workspace. + * + * @param loops True if navigation should loop around, otherwise false. + */ + setNavigationLoops(loops: boolean) { + this.navigationLoops = loops; + } + + /** + * Returns whether or not navigation loops around when reaching the end of + * the workspace. + */ + getNavigationLoops(): boolean { + return this.navigationLoops; + } } registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor); diff --git a/core/menu.ts b/core/menu.ts index a064489bae8..924c20b5a78 100644 --- a/core/menu.ts +++ b/core/menu.ts @@ -16,6 +16,7 @@ import type {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; +import * as idGenerator from './utils/idgenerator.js'; import type {Size} from './utils/size.js'; import * as style from './utils/style.js'; @@ -62,8 +63,12 @@ export class Menu { /** ARIA name for this menu. */ private roleName: aria.Role | null = null; + id: string; + /** Constructs a new Menu instance. */ - constructor() {} + constructor() { + this.id = idGenerator.getNextUniqueId(); + } /** * Add a new menu item to the bottom of this menu. @@ -86,6 +91,7 @@ export class Menu { const element = document.createElement('div'); element.className = 'blocklyMenu'; element.tabIndex = 0; + element.id = this.id; if (this.roleName) { aria.setRole(element, this.roleName); } diff --git a/core/menuitem.ts b/core/menuitem.ts index b3ae33c5c12..aada3aa0ab0 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -52,6 +52,7 @@ export class MenuItem { constructor( private readonly content: string | HTMLElement, private readonly opt_value?: string, + private readonly opt_ariaLabel?: string, ) {} /** @@ -74,10 +75,12 @@ export class MenuItem { const content = document.createElement('div'); content.className = 'blocklyMenuItemContent'; + aria.setRole(content, aria.Role.PRESENTATION); // Add a checkbox for checkable menu items. if (this.checkable) { const checkbox = document.createElement('div'); checkbox.className = 'blocklyMenuItemCheckbox '; + aria.setRole(checkbox, aria.Role.PRESENTATION); content.appendChild(checkbox); } @@ -98,6 +101,9 @@ export class MenuItem { (this.checkable && this.checked) || false, ); aria.setState(element, aria.State.DISABLED, !this.enabled); + if (this.opt_ariaLabel) { + aria.setState(element, aria.State.LABEL, this.opt_ariaLabel); + } return element; } diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 4a53048bc84..ffef106f579 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -20,11 +20,13 @@ import {ConnectionType} from './connection_type.js'; import * as ContextMenu from './contextmenu.js'; import {ContextMenuRegistry} from './contextmenu_registry.js'; import * as eventUtils from './events/utils.js'; +import {inputTypes} from './inputs/input_types.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -332,6 +334,36 @@ export class RenderedConnection const highlightSvg = this.findHighlightSvg(); if (highlightSvg) { highlightSvg.style.display = ''; + aria.setRole(highlightSvg, aria.Role.FIGURE); + const connectionType = + this.type === ConnectionType.INPUT_VALUE ? 'value' : 'statement'; + const roleDescription = `${connectionType} block position`; + aria.setState(highlightSvg, aria.State.ROLEDESCRIPTION, roleDescription); + if (this.type === ConnectionType.NEXT_STATEMENT) { + const parentInput = + this.getParentInput() ?? + this.getSourceBlock() + .getTopStackBlock() + .previousConnection?.targetConnection?.getParentInput(); + if (parentInput && parentInput.type === inputTypes.STATEMENT) { + aria.setState( + highlightSvg, + aria.State.LABEL, + `End ${parentInput.getFieldRowLabel()}`, + ); + } + } else if ( + this.type === ConnectionType.INPUT_VALUE && + this.getSourceBlock().statementInputCount + ) { + aria.setState( + highlightSvg, + aria.State.LABEL, + `${this.getParentInput()?.getFieldRowLabel()}`, + ); + } else { + aria.setState(highlightSvg, aria.State.LABEL, 'Empty'); + } } } @@ -656,7 +688,7 @@ export class RenderedConnection /** See IFocusableNode.canBeFocused. */ canBeFocused(): boolean { - return true; + return this.findHighlightSvg() !== null; } private findHighlightSvg(): SVGPathElement | null { diff --git a/core/renderers/zelos/path_object.ts b/core/renderers/zelos/path_object.ts index 3c304fd6bf8..1804a23f9b7 100644 --- a/core/renderers/zelos/path_object.ts +++ b/core/renderers/zelos/path_object.ts @@ -8,8 +8,8 @@ import type {BlockSvg} from '../../block_svg.js'; import type {Connection} from '../../connection.js'; -import {FocusManager} from '../../focus_manager.js'; import type {BlockStyle} from '../../theme.js'; +import * as aria from '../../utils/aria.js'; import * as dom from '../../utils/dom.js'; import {Svg} from '../../utils/svg.js'; import {PathObject as BasePathObject} from '../common/path_object.js'; @@ -90,20 +90,17 @@ export class PathObject extends BasePathObject { this.setClass_('blocklySelected', enable); if (enable) { if (!this.svgPathSelected) { - this.svgPathSelected = this.svgPath.cloneNode(true) as SVGElement; - this.svgPathSelected.classList.add('blocklyPathSelected'); - // Ensure focus-specific properties don't overlap with the block's path. - dom.removeClass( - this.svgPathSelected, - FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - dom.removeClass( - this.svgPathSelected, - FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + this.svgPathSelected = dom.createSvgElement( + Svg.PATH, + { + 'class': 'blocklyPath blocklyPathSelected', + 'stroke': this.svgPath.getAttribute('stroke') ?? '', + 'fill': this.svgPath.getAttribute('fill') ?? '', + 'd': this.svgPath.getAttribute('d') ?? '', + 'role': aria.Role.PRESENTATION, + }, + this.svgRoot, ); - this.svgPathSelected.removeAttribute('tabindex'); - this.svgPathSelected.removeAttribute('id'); - this.svgRoot.appendChild(this.svgPathSelected); } } else { if (this.svgPathSelected) { diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index f8c95500770..ddf130844b1 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -15,7 +15,9 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; +import {RenderedConnection} from './rendered_connection.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; +import {aria} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import {KeyCodes} from './utils/keycodes.js'; import {Rect} from './utils/rect.js'; @@ -33,6 +35,14 @@ export enum names { PASTE = 'paste', UNDO = 'undo', REDO = 'redo', + READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary', + READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary', + JUMP_TOP_STACK = 'jump_to_top_of_stack', + JUMP_BOTTOM_STACK = 'jump_to_bottom_of_stack', + JUMP_BLOCK_START = 'jump_to_block_start', + JUMP_BLOCK_END = 'jump_to_block_end', + JUMP_FIRST_BLOCK = 'jump_to_first_block', + JUMP_LAST_BLOCK = 'jump_to_last_block', } /** @@ -386,6 +396,263 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * PreconditionFn that returns true if the focused thing is a block or + * belongs to a block (such as field, icon, etc.) + */ +const focusedNodeHasBlockParent = function (workspace: WorkspaceSvg) { + return ( + !workspace.isDragging() && + !getFocusManager().ephemeralFocusTaken() && + !!getFocusManager().getFocusedNode() && + // Either a block or something that has a parent block is focused + !!workspace.getCursor().getSourceBlock() + ); +}; + +/** + * Registers a keyboard shortcut for re-reading the current selected block's + * summary with additional verbosity to help provide context on where the user + * is currently navigated (for screen reader users only). + * + * This works when a block is selected, or some other part of a block + * such as a field or icon. + */ +export function registerReadFullBlockSummary() { + const readFullBlockSummaryShortcut: KeyboardShortcut = { + name: names.READ_FULL_BLOCK_SUMMARY, + preconditionFn: focusedNodeHasBlockParent, + callback(workspace, e) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + const blockSummary = selectedBlock.computeAriaLabel(true); + aria.announceDynamicAriaState(`Current block: ${blockSummary}`); + e.preventDefault(); + return true; + }, + keyCodes: [KeyCodes.I], + }; + ShortcutRegistry.registry.register(readFullBlockSummaryShortcut); +} + +/** + * Registers a keyboard shortcut for re-reading the current selected block's + * parent block summary with additional verbosity to help provide context on + * where the user is currently navigated (for screen reader users only). + */ +export function registerReadBlockParentSummary() { + const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [ + KeyCodes.SHIFT, + ]); + const readBlockParentSummaryShortcut: KeyboardShortcut = { + name: names.READ_BLOCK_PARENT_SUMMARY, + preconditionFn: focusedNodeHasBlockParent, + callback(workspace, e) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + + const toAnnounce = []; + // First go up the chain of output connections and start finding parents from there + // because the outputs of a block are read anyway, so we don't need to repeat them + + let startBlock = selectedBlock; + while (startBlock.outputConnection?.isConnected()) { + startBlock = startBlock.getParent()!; + } + + if (startBlock !== selectedBlock) { + toAnnounce.push( + startBlock.computeAriaLabel(false, true, selectedBlock), + ); + } + + let parent = startBlock.getParent(); + while (parent) { + toAnnounce.push(parent.computeAriaLabel(false, true)); + parent = parent.getParent(); + } + + if (toAnnounce.length) { + toAnnounce.reverse(); + if (!selectedBlock.outputConnection?.isConnected()) { + // The current block was already read out earlier if it has an output connection + toAnnounce.push( + `Current block: ${selectedBlock.computeAriaLabel(false, true)}`, + ); + } + + aria.announceDynamicAriaState(`Parent blocks: ${toAnnounce.join(',')}`); + } else { + aria.announceDynamicAriaState('Current block has no parent'); + } + e.preventDefault(); + return true; + }, + keyCodes: [shiftI], + }; + ShortcutRegistry.registry.register(readBlockParentSummaryShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the block + * that owns the current focused node. + */ +export function registerJumpBlockStart() { + const jumpBlockStartShortcut: KeyboardShortcut = { + name: names.JUMP_BLOCK_START, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + getFocusManager().focusNode(selectedBlock); + return true; + }, + keyCodes: [KeyCodes.HOME], + }; + ShortcutRegistry.registry.register(jumpBlockStartShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the + * last input of the block that owns the current focused node. + */ +export function registerJumpBlockEnd() { + const jumpBlockEndShortcut: KeyboardShortcut = { + name: names.JUMP_BLOCK_END, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + const inputs = selectedBlock.inputList; + if (!inputs.length) return false; + const connection = inputs[inputs.length - 1].connection; + if (!connection || !(connection instanceof RenderedConnection)) + return false; + getFocusManager().focusNode(connection); + return true; + }, + keyCodes: [KeyCodes.END], + }; + ShortcutRegistry.registry.register(jumpBlockEndShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the top block + * in the current stack. + */ +export function registerJumpTopStack() { + const jumpTopStackShortcut: KeyboardShortcut = { + name: names.JUMP_TOP_STACK, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + const topOfStack = selectedBlock.getRootBlock(); + getFocusManager().focusNode(topOfStack); + return true; + }, + keyCodes: [KeyCodes.PAGE_UP], + }; + ShortcutRegistry.registry.register(jumpTopStackShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the bottom block + * in the current stack. + */ +export function registerJumpBottomStack() { + const jumpBottomStackShortcut: KeyboardShortcut = { + name: names.JUMP_BOTTOM_STACK, + preconditionFn: (workspace) => { + return !workspace.isFlyout && focusedNodeHasBlockParent(workspace); + }, + callback(workspace) { + const selectedBlock = workspace.getCursor().getSourceBlock(); + if (!selectedBlock) return false; + // To get the bottom block in a stack, first go to the top of the stack + // Then get the last next connection + // Then get the last descendant of that block + const lastBlock = selectedBlock + .getRootBlock() + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (!lastBlock) return false; + const descendants = lastBlock.getDescendants(true); + const bottomOfStack = descendants[descendants.length - 1]; + getFocusManager().focusNode(bottomOfStack); + return true; + }, + keyCodes: [KeyCodes.PAGE_DOWN], + }; + ShortcutRegistry.registry.register(jumpBottomStackShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the first + * block in the workspace. + */ +export function registerJumpFirstBlock() { + const ctrlHome = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.HOME, + [KeyCodes.CTRL], + ); + const metaHome = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.HOME, + [KeyCodes.META], + ); + const jumpFirstBlockShortcut: KeyboardShortcut = { + name: names.JUMP_FIRST_BLOCK, + preconditionFn: (workspace) => { + return ( + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() + ); + }, + callback(workspace) { + const topBlocks = workspace.getTopBlocks(true); + if (!topBlocks.length) return false; + getFocusManager().focusNode(topBlocks[0]); + return true; + }, + keyCodes: [ctrlHome, metaHome], + }; + ShortcutRegistry.registry.register(jumpFirstBlockShortcut); +} + +/** + * Registers a keyboard shortcut that sets the focus to the last + * block in the workspace. + */ +export function registerJumpLastBlock() { + const ctrlEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [ + KeyCodes.CTRL, + ]); + const metaEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [ + KeyCodes.META, + ]); + const jumpLastBlockShortcut: KeyboardShortcut = { + name: names.JUMP_LAST_BLOCK, + preconditionFn: (workspace) => { + return ( + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() + ); + }, + callback(workspace) { + const allBlocks = workspace.getAllBlocks(true); + if (!allBlocks.length) return false; + getFocusManager().focusNode(allBlocks[allBlocks.length - 1]); + return true; + }, + keyCodes: [ctrlEnd, metaEnd], + }; + ShortcutRegistry.registry.register(jumpLastBlockShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -400,6 +667,14 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); + registerReadFullBlockSummary(); + registerReadBlockParentSummary(); + registerJumpTopStack(); + registerJumpBottomStack(); + registerJumpBlockStart(); + registerJumpBlockEnd(); + registerJumpFirstBlock(); + registerJumpLastBlock(); } registerDefaultShortcuts(); diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3fcd..60694aa8073 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -307,6 +307,7 @@ export class ToolboxCategory if (className) { dom.addClass(toolboxLabel, className); } + aria.setState(toolboxLabel, aria.State.LABEL, `${name} blocks`); return toolboxLabel; } @@ -380,6 +381,15 @@ export class ToolboxCategory return ''; } + /** + * Returns the colour of this category. + * + * @internal + */ + getColour() { + return this.colour_; + } + /** * Gets the HTML element that is clickable. * The parent toolbox element receives clicks. The parent toolbox will add an diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 5048ff1269d..5cfe7e84f64 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -132,10 +132,10 @@ export class CollapsibleToolboxCategory const subCategories = this.getChildToolboxItems(); this.subcategoriesDiv_ = this.createSubCategoriesDom_(subCategories); - aria.setRole(this.subcategoriesDiv_, aria.Role.GROUP); this.htmlDiv_!.appendChild(this.subcategoriesDiv_); this.closeIcon_(this.iconDom_); aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false); + aria.setRole(this.htmlDiv_!, aria.Role.TREEITEM); return this.htmlDiv_!; } @@ -179,6 +179,7 @@ export class CollapsibleToolboxCategory newCategory.getClickTarget()?.setAttribute('id', newCategory.getId()); } } + aria.setRole(contentsContainer, aria.Role.GROUP); return contentsContainer; } diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index cd5ed245a04..517ed16016f 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -14,6 +14,7 @@ import * as Css from '../css.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import * as registry from '../registry.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import type * as toolbox from '../utils/toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; @@ -63,6 +64,9 @@ export class ToolboxSeparator extends ToolboxItem { dom.addClass(container, className); } this.htmlDiv = container; + + aria.setRole(this.htmlDiv, aria.Role.SEPARATOR); + return container; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index f34034d3399..dd3c0db6be8 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -35,6 +35,7 @@ import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.j import type {IStyleable} from '../interfaces/i_styleable.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; +import {Msg} from '../msg.js'; import * as registry from '../registry.js'; import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; @@ -186,7 +187,6 @@ export class Toolbox container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); - aria.setRole(this.contentsDiv_, aria.Role.TREE); container.appendChild(this.contentsDiv_); svg.parentNode!.insertBefore(container, svg); @@ -205,6 +205,12 @@ export class Toolbox toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v'); dom.addClass(toolboxContainer, 'blocklyToolbox'); toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR'); + aria.setRole(toolboxContainer, aria.Role.REGION); + aria.setState( + toolboxContainer, + aria.State.LABEL, + Msg['TOOLBOX_ARIA_LABEL'], + ); return toolboxContainer; } @@ -219,6 +225,12 @@ export class Toolbox if (this.isHorizontal()) { contentsContainer.style.flexDirection = 'row'; } + aria.setRole(contentsContainer, aria.Role.TREE); + aria.setState( + contentsContainer, + aria.State.LABEL, + Msg['TOOLBOX_ARIA_LABEL'], + ); return contentsContainer; } @@ -876,11 +888,6 @@ export class Toolbox this.selectedItem_ = null; this.previouslySelectedItem_ = item; item.setSelected(false); - aria.setState( - this.contentsDiv_ as Element, - aria.State.ACTIVEDESCENDANT, - '', - ); } /** @@ -896,11 +903,6 @@ export class Toolbox this.selectedItem_ = newItem; this.previouslySelectedItem_ = oldItem; newItem.setSelected(true); - aria.setState( - this.contentsDiv_ as Element, - aria.State.ACTIVEDESCENDANT, - newItem.getId(), - ); } /** diff --git a/core/utils/aria.ts b/core/utils/aria.ts index d997b8d0af0..8535f89830b 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -17,11 +17,6 @@ const ROLE_ATTRIBUTE = 'role'; * Copied from Closure's goog.a11y.aria.Role */ export enum Role { - // ARIA role for an interactive control of tabular data. - GRID = 'grid', - - // ARIA role for a cell in a grid. - GRIDCELL = 'gridcell', // ARIA role for a group of related elements like tree item siblings. GROUP = 'group', @@ -33,16 +28,13 @@ export enum Role { // ARIA role for menu item elements. MENUITEM = 'menuitem', - // ARIA role for a checkbox box element inside a menu. - MENUITEMCHECKBOX = 'menuitemcheckbox', + // ARIA role for option items that are children of combobox, listbox, menu, // radiogroup, or tree elements. OPTION = 'option', // ARIA role for ignorable cosmetic elements with no semantic significance. PRESENTATION = 'presentation', - // ARIA role for a row of cells in a grid. - ROW = 'row', // ARIA role for a tree. TREE = 'tree', @@ -54,6 +46,18 @@ export enum Role { // ARIA role for a live region providing information. STATUS = 'status', + + IMAGE = 'image', + FIGURE = 'figure', + BUTTON = 'button', + CHECKBOX = 'checkbox', + TEXTBOX = 'textbox', + COMBOBOX = 'combobox', + SPINBUTTON = 'spinbutton', + REGION = 'region', + GENERIC = 'generic', + LIST = 'list', + LISTITEM = 'listitem', } /** @@ -64,10 +68,6 @@ export enum State { // ARIA property for setting the currently active descendant of an element, // for example the selected item in a list box. Value: ID of an element. ACTIVEDESCENDANT = 'activedescendant', - // ARIA property defines the total number of columns in a table, grid, or - // treegrid. - // Value: integer. - COLCOUNT = 'colcount', // ARIA state for a disabled item. Value: one of {true, false}. DISABLED = 'disabled', @@ -89,19 +89,11 @@ export enum State { // ARIA property for setting the level of an element in the hierarchy. // Value: integer. LEVEL = 'level', - // ARIA property indicating if the element is horizontal or vertical. - // Value: one of {'vertical', 'horizontal'}. - ORIENTATION = 'orientation', // ARIA property that defines an element's number of position in a list. // Value: integer. POSINSET = 'posinset', - // ARIA property defines the total number of rows in a table, grid, or - // treegrid. - // Value: integer. - ROWCOUNT = 'rowcount', - // ARIA state for setting the currently selected item in the list. // Value: one of {true, false, undefined}. SELECTED = 'selected', @@ -114,6 +106,8 @@ export enum State { // ARIA property for slider minimum value. Value: number. VALUEMIN = 'valuemin', + VALUENOW = 'valuenow', + // ARIA property for live region chattiness. // Value: one of {polite, assertive, off}. LIVE = 'live', @@ -121,29 +115,55 @@ export enum State { // ARIA property for removing elements from the accessibility tree. // Value: one of {true, false, undefined}. HIDDEN = 'hidden', + + ROLEDESCRIPTION = 'roledescription', + OWNS = 'owns', + HASPOPUP = 'haspopup', + CONTROLS = 'controls', + CHECKED = 'checked', } /** - * Sets the role of an element. + * Updates the specific role for the specified element. * - * Similar to Closure's goog.a11y.aria + * @param element The element whose ARIA role should be changed. + * @param roleName The new role for the specified element, or null if its role + * should be cleared. + */ +export function setRole(element: Element, roleName: Role | null) { + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else element.removeAttribute(ROLE_ATTRIBUTE); +} + +/** + * Returns the ARIA role of the specified element, or null if it either doesn't + * have a designated role or if that role is unknown. * - * @param element DOM node to set role of. - * @param roleName Role name. + * @param element The element from which to retrieve its ARIA role. + * @returns The ARIA role of the element, or null if undefined or unknown. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function getRole(element: Element): Role | null { + // This is an unsafe cast which is why it needs to be checked to ensure that + // it references a valid role. + const currentRoleName = element.getAttribute(ROLE_ATTRIBUTE) as Role; + if (Object.values(Role).includes(currentRoleName)) { + return currentRoleName; + } + return null; } /** - * Sets the state or property of an element. - * Copied from Closure's goog.a11y.aria + * Sets the specified ARIA state by its name and value for the specified + * element. * - * @param element DOM node where we set state. - * @param stateName State attribute being set. - * Automatically adds prefix 'aria-' to the state name if the attribute is - * not an extra attribute. - * @param value Value for the state attribute. + * Note that the type of value is not validated against the specific type of + * state being changed, so it's up to callers to ensure the correct value is + * used for the given state. + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to change. + * @param value The new value to specify for the provided state. */ export function setState( element: Element, @@ -156,3 +176,55 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +/** + * Clears the specified ARIA state by removing any related attributes from the + * specified element that have been set using setState(). + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to clear from the provided element. + */ +export function clearState(element: Element, stateName: State) { + element.removeAttribute(ARIA_PREFIX + stateName); +} + +/** + * Returns a string representation of the specified state for the specified + * element, or null if it's not defined or specified. + * + * Note that an explicit set state of 'null' will return the 'null' string, not + * the value null. + * + * @param element The element whose state is being retrieved. + * @param stateName The state to retrieve. + * @returns The string representation of the requested state for the specified + * element, or null if not defined. + */ +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + return element.getAttribute(attrStateName); +} + +/** + * Assertively requests that the specified text be read to the user if a screen + * reader is currently active. + * + * This relies on a centrally managed ARIA live region that is hidden from the + * visual DOM. This live region is assertive, meaning it will interrupt other + * text being read. + * + * Callers should use this judiciously. It's often considered bad practice to + * over-announce information that can be inferred from other sources on the + * page, so this ought to be used only when certain context cannot be easily + * determined (such as dynamic states that may not have perfect ARIA + * representations or indications). + * + * @param text The text to read to the user. + */ +export function announceDynamicAriaState(text: string) { + const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); + if (!ariaAnnouncementSpan) { + throw new Error('Expected element with id blocklyAriaAnnounce to exist.'); + } + ariaAnnouncementSpan.innerHTML = text; +} diff --git a/core/utils/dom.ts b/core/utils/dom.ts index 4087984151c..a7fc35dc3a5 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -6,7 +6,8 @@ // Former goog.module ID: Blockly.utils.dom -import type {Svg} from './svg.js'; +import * as aria from './aria.js'; +import {Svg} from './svg.js'; /** * Required name space for SVG elements. @@ -62,6 +63,9 @@ export function createSvgElement( if (opt_parent) { opt_parent.appendChild(e); } + if (name === Svg.SVG || name === Svg.G) { + aria.setRole(e, aria.Role.GENERIC); + } return e; } diff --git a/core/workspace_audio.ts b/core/workspace_audio.ts index 1759b30edbb..ff70c2776aa 100644 --- a/core/workspace_audio.ts +++ b/core/workspace_audio.ts @@ -124,20 +124,11 @@ export class WorkspaceAudio { * @param opt_volume Volume of sound (0-1). */ play(name: string, opt_volume?: number) { - if (this.muted) { - return; - } + if (!this.isPlayingAllowed()) return; + const sound = this.sounds.get(name); if (sound) { - // Don't play one sound on top of another. - const now = new Date(); - if ( - this.lastSound !== null && - now.getTime() - this.lastSound.getTime() < SOUND_LIMIT - ) { - return; - } - this.lastSound = now; + this.lastSound = new Date(); let mySound; if (userAgent.IPAD || userAgent.ANDROID) { // Creating a new audio node causes lag in Android and iPad. Android @@ -168,4 +159,50 @@ export class WorkspaceAudio { getMuted(): boolean { return this.muted; } + + /** + * Returns whether or not playing sounds is currently allowed. + * + * @returns False if audio is muted or a sound has just been played, otherwise + * true. + */ + private isPlayingAllowed() { + const now = new Date(); + + if ( + this.getMuted() || + (this.lastSound !== null && + now.getTime() - this.lastSound.getTime() < SOUND_LIMIT) + ) { + return false; + } + return true; + } + + /** + * Plays a brief beep at the given frequency. + * + * @param tone The frequency of the beep to play. + */ + beep(tone: number) { + if (!this.isPlayingAllowed()) return; + this.lastSound = new Date(); + + const context = new AudioContext(); + + const oscillator = context.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(tone, context.currentTime); + + const gainNode = context.createGain(); + gainNode.gain.setValueAtTime(0, context.currentTime); + gainNode.gain.linearRampToValueAtTime(0.5, context.currentTime + 0.01); // Fade in + gainNode.gain.linearRampToValueAtTime(0, context.currentTime + 0.2); // Fade out + + oscillator.connect(gainNode); + gainNode.connect(context.destination); + + oscillator.start(context.currentTime); + oscillator.stop(context.currentTime + 0.2); + } } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index af395b077e5..3e0c8324e7b 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -19,7 +19,7 @@ import './events/events_theme_change.js'; import './events/events_viewport.js'; import type {Block} from './block.js'; -import type {BlockSvg} from './block_svg.js'; +import {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; import {TextInputBubble} from './bubbles/textinput_bubble.js'; @@ -41,7 +41,7 @@ import {Abstract as AbstractEvent} from './events/events.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import {Flyout} from './flyout_base.js'; -import type {FlyoutButton} from './flyout_button.js'; +import {FlyoutButton} from './flyout_button.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; @@ -51,10 +51,7 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; import type {IDragTarget} from './interfaces/i_drag_target.js'; import type {IFlyout} from './interfaces/i_flyout.js'; -import { - isFocusableNode, - type IFocusableNode, -} from './interfaces/i_focusable_node.js'; +import {type IFocusableNode} from './interfaces/i_focusable_node.js'; 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'; @@ -763,13 +760,22 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); - if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); + + let ariaLabel = null; + let role: aria.Role | null = null; + if (this.isFlyout) { + ariaLabel = 'Flyout'; + // Default to region, but this may change during flyout initialization. + role = aria.Role.REGION; + } else if (this.isMutator) { + ariaLabel = 'Mutator Workspace'; + role = aria.Role.GENERIC; + } else { + ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; + role = aria.Role.REGION; } + aria.setRole(this.svgGroup_, role); + aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); // Note that a alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the @@ -797,7 +803,11 @@ export class WorkspaceSvg this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer(); - if (!this.isFlyout) { + if (this.isFlyout) { + // Use the block canvas as the primary list for nesting. + aria.setRole(this.svgBlockCanvas_, aria.Role.LIST); + aria.setState(this.svgBlockCanvas_, aria.State.LABEL, ariaLabel); + } else { browserEvents.conditionalBind( this.svgGroup_, 'pointerdown', @@ -2736,10 +2746,7 @@ export class WorkspaceSvg return ( flyout .getContents() - .find((flyoutItem) => { - const element = flyoutItem.getElement(); - return isFocusableNode(element) && element.canBeFocused(); - }) + .find((flyoutItem) => flyoutItem.getElement().canBeFocused()) ?.getElement() ?? null ); } @@ -2796,11 +2803,7 @@ export class WorkspaceSvg if (this.isFlyout && flyout) { for (const flyoutItem of flyout.getContents()) { const elem = flyoutItem.getElement(); - if ( - isFocusableNode(elem) && - elem.canBeFocused() && - elem.getFocusableElement().id === id - ) { + if (elem.canBeFocused() && elem.getFocusableElement().id === id) { return elem; } } @@ -2936,6 +2939,33 @@ export class WorkspaceSvg setNavigator(newNavigator: Navigator) { this.navigator = newNavigator; } + + recomputeAriaTree() { + // Flyout workspaces require special arrangement to account for items. + const flyout = this.targetWorkspace?.getFlyout(); + if (this.isFlyout && flyout) { + const focusableItems = flyout + .getContents() + .map((item) => item.getElement()) + .filter((item) => item.canBeFocused()); + focusableItems.forEach((item, index) => { + // This is rather hacky and may need more thought, but it's a + // consequence of actual button (non-label) FlyoutButtons requiring two + // distinct roles (a parent treeitem and a child button that actually + // holds focus). + // TODO: Figure out how to generalize this for arbitrary FlyoutItems + // that may require special handling like this (i.e. a treeitem wrapping + // an actual focusable element). + const treeItemElem = + item instanceof FlyoutButton + ? item.getSvgRoot() + : item.getFocusableElement(); + aria.setState(treeItemElem, aria.State.POSINSET, index + 1); + aria.setState(treeItemElem, aria.State.SETSIZE, focusableItems.length); + aria.setState(treeItemElem, aria.State.LEVEL, 1); // They are always top-level. + }); + } + } } /** diff --git a/msg/json/en.json b/msg/json/en.json index ec5862ae465..f9840a8f44b 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2025-09-08 16:26:57.642330", + "lastupdated": "2025-09-23 23:27:37.312782", "locale": "en", "messagedocumentation" : "qqq" }, @@ -12,6 +12,7 @@ "ADD_COMMENT": "Add Comment", "REMOVE_COMMENT": "Remove Comment", "DUPLICATE_COMMENT": "Duplicate Comment", + "COLLAPSE_COMMENT": "Collapse Comment", "EXTERNAL_INPUTS": "External Inputs", "INLINE_INPUTS": "Inline Inputs", "DELETE_BLOCK": "Delete Block", @@ -31,6 +32,7 @@ "CHANGE_VALUE_TITLE": "Change value:", "RENAME_VARIABLE": "Rename variable...", "RENAME_VARIABLE_TITLE": "Rename all '%1' variables to:", + "ARIA_LABEL_FOR_VARIABLE_NAME": "Variable '%1'", "NEW_VARIABLE": "Create variable...", "NEW_STRING_VARIABLE": "Create string variable...", "NEW_NUMBER_VARIABLE": "Create number variable...", @@ -395,6 +397,7 @@ "PROCEDURES_IFRETURN_WARNING": "Warning: This block may be used only within a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "Say something...", "WORKSPACE_ARIA_LABEL": "Blockly Workspace", + "TOOLBOX_ARIA_LABEL": "Toolbox", "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", "DIALOG_CANCEL": "Cancel", diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 5e03efc4153..f6980bd422d 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -19,6 +19,7 @@ "ADD_COMMENT": "context menu - Add a descriptive comment to the selected block.", "REMOVE_COMMENT": "context menu - Remove the descriptive comment from the selected block.", "DUPLICATE_COMMENT": "context menu - Make a copy of the selected workspace comment.\n{{Identical|Duplicate}}", + "COLLAPSE_COMMENT": "context menu - Collapse the selected workspace comment.", "EXTERNAL_INPUTS": "context menu - Change from 'external' to 'inline' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|INLINE INPUTS}}.", "INLINE_INPUTS": "context menu - Change from 'internal' to 'external' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|EXTERNAL INPUTS}}.", "DELETE_BLOCK": "context menu - Permanently delete the selected block.", @@ -38,6 +39,7 @@ "CHANGE_VALUE_TITLE": "prompt - This message is seen on mobile devices and the Opera browser. With most browsers, users can edit numeric values in blocks by just clicking and typing. Opera does not allow this and mobile browsers may have issues with in-line textareas. So we prompt users with this message (usually a popup) to change a value.", "RENAME_VARIABLE": "dropdown choice - When the user clicks on a variable block, this is one of the dropdown menu choices. It is used to rename the current variable. See [https://github.com/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/blockly/wiki/Variables#dropdown-menu].", "RENAME_VARIABLE_TITLE": "prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/blockly/wiki/Variables#dropdown-menu].\n\nParameters:\n* %1 - the name of the variable to be renamed.", + "ARIA_LABEL_FOR_VARIABLE_NAME": "dropdown choice - Provides screen reader users with a label that contextualizes a variable as an actual variable with its name.", "NEW_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", "NEW_STRING_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", "NEW_NUMBER_VARIABLE": "button text - Text on the button used to launch the variable creation dialogue.", @@ -402,6 +404,7 @@ "PROCEDURES_IFRETURN_WARNING": "warning - This appears if the user tries to use this block outside of a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "comment text - This text appears in a new workspace comment, to hint that the user can type here.", "WORKSPACE_ARIA_LABEL": "workspace - This text is read out when a user navigates to the workspace while using a screen reader.", + "TOOLBOX_ARIA_LABEL": "This text is read out when a user navigates to the toolbox while using a screen reader.", "COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.", "DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}", "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}", diff --git a/msg/messages.js b/msg/messages.js index 1095ae05776..575e97ea59f 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -9,13 +9,13 @@ * * After modifying this file, run: * - * npm run generate:langfiles + * npm run messages * * to regenerate json/{en,qqq,constants,synonyms}.json. * * To convert all of the json files to .js files, run: * - * npm run build:langfiles + * npm run langfiles */ 'use strict'; @@ -85,6 +85,9 @@ Blockly.Msg.REMOVE_COMMENT = 'Remove Comment'; /// context menu - Make a copy of the selected workspace comment.\n{{Identical|Duplicate}} Blockly.Msg.DUPLICATE_COMMENT = 'Duplicate Comment'; /** @type {string} */ +/// context menu - Collapse the selected workspace comment. +Blockly.Msg.COLLAPSE_COMMENT = 'Collapse Comment'; +/** @type {string} */ /// context menu - Change from 'external' to 'inline' mode for displaying blocks used as inputs to the selected block. See [[Translating:Blockly#context_menus]].\n\nThe opposite of {{msg-blockly|INLINE INPUTS}}. Blockly.Msg.EXTERNAL_INPUTS = 'External Inputs'; /** @type {string} */ @@ -143,6 +146,9 @@ Blockly.Msg.RENAME_VARIABLE = 'Rename variable...'; /** @type {string} */ /// prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/google/blockly/wiki/Variables#dropdown-menu https://github.com/google/blockly/wiki/Variables#dropdown-menu].\n\nParameters:\n* %1 - the name of the variable to be renamed. Blockly.Msg.RENAME_VARIABLE_TITLE = 'Rename all "%1" variables to:'; +/** @type {string} */ +/// dropdown choice - Provides screen reader users with a label that contextualizes a variable as an actual variable with its name. +Blockly.Msg.ARIA_LABEL_FOR_VARIABLE_NAME = 'Variable "%1"'; // Variable creation /** @type {string} */ @@ -1604,6 +1610,11 @@ Blockly.Msg.WORKSPACE_COMMENT_DEFAULT_TEXT = 'Say something...'; /// using a screen reader. Blockly.Msg.WORKSPACE_ARIA_LABEL = 'Blockly Workspace'; +/** @type {string} */ +/// This text is read out when a user navigates to the toolbox while using a +/// screen reader. +Blockly.Msg.TOOLBOX_ARIA_LABEL = 'Toolbox'; + /** @type {string} */ /// warning - This appears if the user collapses a block, and blocks inside /// that block have warnings attached to them. It should inform the user that the @@ -1626,7 +1637,7 @@ Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents'; /// menu label - Contextual menu item that starts a keyboard-driven block move. Blockly.Msg.MOVE_BLOCK = 'Move Block'; /** @type {string} */ -/// Name of the Microsoft Windows operating system displayed in a list of +/// Name of the Microsoft Windows operating system displayed in a list of /// keyboard shortcuts. Blockly.Msg.WINDOWS = 'Windows'; /** @type {string} */ diff --git a/package-lock.json b/package-lock.json index 1822c76041b..62e2ed6a434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/keyboard-navigation": "^4.0.0-beta.0", "@blockly/theme-modern": "^7.0.1", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", @@ -232,9 +232,9 @@ } }, "node_modules/@blockly/keyboard-navigation": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-3.0.1.tgz", - "integrity": "sha512-qSOPqsqRgkSLEoUeEZc81PWe558pXqY0e+4jkRODoAD+I1hMpCoD+6ivveRp7Jpb8WE1lj2PrAFOVuIVpphjHA==", + "version": "4.0.0-beta.0", + "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-4.0.0-beta.0.tgz", + "integrity": "sha512-jeFHyXNrFMFk6ZFlcdjJvmSXkiq8w/y/h49pZSe3E6o01pUtuVVf4PEUKj1KW2v+F0IwlhyHY5cvPu+JudyZKg==", "dev": true, "peerDependencies": { "blockly": "^12.3.0" diff --git a/package.json b/package.json index 8a68320d213..895231ccdc1 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "devDependencies": { "@blockly/block-test": "^7.0.2", "@blockly/dev-tools": "^9.0.2", - "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/keyboard-navigation": "^4.0.0-beta.0", "@blockly/theme-modern": "^7.0.1", "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 02426ae26b8..6816cf8c1e7 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -136,22 +136,22 @@ suite('Cursor', function () { assert.equal(curNode, fieldBlock); }); - test('Prev - From previous connection does skip over next connection', function () { + test('Prev - From previous connection does not skip over next connection', function () { const prevConnection = this.blocks.B.previousConnection; const prevConnectionNode = prevConnection; this.cursor.setCurNode(prevConnectionNode); this.cursor.prev(); const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.A); + assert.equal(curNode, this.blocks.A.nextConnection); }); - test('Prev - From first block loop to last block', function () { + test('Prev - From first block loop to last statement input', function () { const prevConnection = this.blocks.A; const prevConnectionNode = prevConnection; this.cursor.setCurNode(prevConnectionNode); this.cursor.prev(); const curNode = this.cursor.getCurNode(); - assert.equal(curNode, this.blocks.D); + assert.equal(curNode, this.blocks.D.getInput('NAME4').connection); }); test('Out - From field does not skip over block node', function () { @@ -253,12 +253,16 @@ suite('Cursor', function () { test('In - from field in nested statement block to next nested statement block', function () { this.cursor.setCurNode(this.secondStatement.getField('NAME')); this.cursor.in(); + // Skip over the next connection + this.cursor.in(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.thirdStatement); }); test('In - from field in nested statement block to next stack', function () { this.cursor.setCurNode(this.thirdStatement.getField('NAME')); this.cursor.in(); + // Skip over the next connection + this.cursor.in(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.multiStatement2); }); @@ -266,6 +270,8 @@ suite('Cursor', function () { test('Out - from nested statement block to last field of previous nested statement block', function () { this.cursor.setCurNode(this.thirdStatement); this.cursor.out(); + // Skip over the previous next connection + this.cursor.out(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.secondStatement.getField('NAME')); }); @@ -273,6 +279,8 @@ suite('Cursor', function () { test('Out - from root block to last field of last nested statement block in previous stack', function () { this.cursor.setCurNode(this.multiStatement2); this.cursor.out(); + // Skip over the previous next connection + this.cursor.out(); const curNode = this.cursor.getCurNode(); assert.equal(curNode, this.thirdStatement.getField('NAME')); }); @@ -334,7 +342,7 @@ suite('Cursor', function () { }); suite('one empty block', function () { setup(function () { - this.blockA = this.workspace.newBlock('empty_block'); + this.blockA = createRenderedBlock(this.workspace, 'empty_block'); }); teardown(function () { this.workspace.clear(); @@ -351,7 +359,7 @@ suite('Cursor', function () { suite('one stack block', function () { setup(function () { - this.blockA = this.workspace.newBlock('stack_block'); + this.blockA = createRenderedBlock(this.workspace, 'stack_block'); }); teardown(function () { this.workspace.clear(); @@ -368,7 +376,7 @@ suite('Cursor', function () { suite('one row block', function () { setup(function () { - this.blockA = this.workspace.newBlock('row_block'); + this.blockA = createRenderedBlock(this.workspace, 'row_block'); }); teardown(function () { this.workspace.clear(); @@ -384,7 +392,7 @@ suite('Cursor', function () { }); suite('one c-hat block', function () { setup(function () { - this.blockA = this.workspace.newBlock('c_hat_block'); + this.blockA = createRenderedBlock(this.workspace, 'c_hat_block'); }); teardown(function () { this.workspace.clear(); @@ -395,7 +403,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); }); }); diff --git a/tests/mocha/field_dropdown_test.js b/tests/mocha/field_dropdown_test.js index a1731e81281..81964c35e64 100644 --- a/tests/mocha/field_dropdown_test.js +++ b/tests/mocha/field_dropdown_test.js @@ -230,9 +230,9 @@ suite('Dropdown Fields', function () { assert.deepEqual(this.field.prefixField, 'a'); assert.deepEqual(this.field.suffixField, 'b'); assert.deepEqual(this.field.getOptions(), [ - ['d', 'D'], - ['e', 'E'], - ['f', 'F'], + ['d', 'D', undefined], + ['e', 'E', undefined], + ['f', 'F', undefined], ]); }); test('With an empty array of options throws', function () { diff --git a/tests/mocha/field_variable_test.js b/tests/mocha/field_variable_test.js index c2b40326f2f..0a851f44cd8 100644 --- a/tests/mocha/field_variable_test.js +++ b/tests/mocha/field_variable_test.js @@ -201,9 +201,7 @@ suite('Variable Fields', function () { Blockly.FieldVariable.dropdownCreate.call(fieldVariable); // Expect variable options, a rename option, and a delete option. assert.lengthOf(dropdownOptions, expectedVarOptions.length + 2); - for (let i = 0, option; (option = expectedVarOptions[i]); i++) { - assert.deepEqual(dropdownOptions[i], option); - } + assert.deepEqual(dropdownOptions.slice(0, -2), expectedVarOptions); assert.include(dropdownOptions[dropdownOptions.length - 2][0], 'Rename'); assert.include(dropdownOptions[dropdownOptions.length - 1][0], 'Delete'); @@ -217,8 +215,8 @@ suite('Variable Fields', function () { new Blockly.FieldVariable('name2'), ); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], ]); }); test('Contains variables created after field', function () { @@ -230,8 +228,8 @@ suite('Variable Fields', function () { // Expect that variables created after field creation will show up too. this.workspace.createVariable('name2', '', 'id2'); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], ]); }); test('Contains variables created before and after field', function () { @@ -245,9 +243,9 @@ suite('Variable Fields', function () { // Expect that variables created after field creation will show up too. this.workspace.createVariable('name3', '', 'id3'); assertDropdownContents(fieldVariable, [ - ['name1', 'id1'], - ['name2', 'id2'], - ['name3', 'id3'], + ['name1', 'id1', "Variable 'name1'"], + ['name2', 'id2', "Variable 'name2'"], + ['name3', 'id3', "Variable 'name3'"], ]); }); }); diff --git a/tests/mocha/navigation_test.js b/tests/mocha/navigation_test.js index 38dc88894b1..37972318d50 100644 --- a/tests/mocha/navigation_test.js +++ b/tests/mocha/navigation_test.js @@ -5,10 +5,10 @@ */ import {assert} from '../../node_modules/chai/index.js'; +import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, sharedTestTeardown, - workspaceTeardown, } from './test_helpers/setup_teardown.js'; suite('Navigation', function () { @@ -89,13 +89,28 @@ suite('Navigation', function () { ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.navigator = this.workspace.getNavigator(); - const statementInput1 = this.workspace.newBlock('input_statement'); - const statementInput2 = this.workspace.newBlock('input_statement'); - 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'); + const statementInput1 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput2 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput3 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const statementInput4 = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const fieldWithOutput = createRenderedBlock(this.workspace, 'field_input'); + const doubleValueInput = createRenderedBlock( + this.workspace, + 'double_value_input', + ); + const valueInput = createRenderedBlock(this.workspace, 'value_input'); statementInput1.nextConnection.connect(statementInput2.previousConnection); statementInput1.inputList[0].connection.connect( @@ -355,13 +370,25 @@ suite('Navigation', function () { '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'); + const noNextConnection = createRenderedBlock( + this.workspace, + 'top_connection', + ); + const fieldAndInputs = createRenderedBlock( + this.workspace, + 'fields_and_input', + ); + const twoFields = createRenderedBlock(this.workspace, 'two_fields'); + const fieldAndInputs2 = createRenderedBlock( + this.workspace, + 'fields_and_input2', + ); + const noPrevConnection = createRenderedBlock( + this.workspace, + 'start_block', + ); + const hiddenField = createRenderedBlock(this.workspace, 'hidden_field'); + const hiddenInput = createRenderedBlock(this.workspace, 'hidden_input'); this.blocks.noNextConnection = noNextConnection; this.blocks.fieldAndInputs = fieldAndInputs; this.blocks.twoFields = twoFields; @@ -373,28 +400,47 @@ suite('Navigation', function () { 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'); - const fieldWithOutput2 = this.workspace.newBlock('field_input'); + const dummyInput = createRenderedBlock(this.workspace, 'dummy_input'); + const dummyInputValue = createRenderedBlock( + this.workspace, + 'dummy_inputValue', + ); + const fieldWithOutput2 = createRenderedBlock( + this.workspace, + 'field_input', + ); this.blocks.dummyInput = dummyInput; this.blocks.dummyInputValue = dummyInputValue; this.blocks.fieldWithOutput2 = fieldWithOutput2; - const secondBlock = this.workspace.newBlock('input_statement'); - const outputNextBlock = this.workspace.newBlock('output_next'); + const secondBlock = createRenderedBlock( + this.workspace, + 'input_statement', + ); + const outputNextBlock = createRenderedBlock( + this.workspace, + 'output_next', + ); this.blocks.secondBlock = secondBlock; this.blocks.outputNextBlock = outputNextBlock; - const buttonBlock = this.workspace.newBlock('buttons', 'button_block'); - const buttonInput1 = this.workspace.newBlock( + const buttonBlock = createRenderedBlock( + this.workspace, + 'buttons', + 'button_block', + ); + const buttonInput1 = createRenderedBlock( + this.workspace, 'field_input', 'button_input1', ); - const buttonInput2 = this.workspace.newBlock( + const buttonInput2 = createRenderedBlock( + this.workspace, 'field_input', 'button_input2', ); - const buttonNext = this.workspace.newBlock( + const buttonNext = createRenderedBlock( + this.workspace, 'input_statement', 'button_next', ); @@ -420,15 +466,6 @@ suite('Navigation', function () { this.workspace.cleanUp(); }); suite('Next', function () { - setup(function () { - this.singleBlockWorkspace = new Blockly.Workspace(); - const singleBlock = this.singleBlockWorkspace.newBlock('two_fields'); - this.blocks.singleBlock = singleBlock; - }); - teardown(function () { - workspaceTeardown.call(this, this.singleBlockWorkspace); - }); - test('fromPreviousToBlock', function () { const prevConnection = this.blocks.statementInput1.previousConnection; const nextNode = this.navigator.getNextSibling(prevConnection); @@ -471,8 +508,6 @@ suite('Navigation', function () { }); test('fromFieldToNestedBlock', function () { const field = this.blocks.statementInput1.inputList[0].fieldRow[1]; - const inputConnection = - this.blocks.statementInput1.inputList[0].connection; const nextNode = this.navigator.getNextSibling(field); assert.equal(nextNode, this.blocks.fieldWithOutput); }); @@ -531,12 +566,13 @@ suite('Navigation', function () { ); assert.equal(nextNode, field); }); - test('fromBlockToFieldSkippingInput', function () { - const field = this.blocks.buttonBlock.getField('BUTTON3'); + test('fromInputToStatementConnection', function () { + const connection = + this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getNextSibling( this.blocks.buttonInput2, ); - assert.equal(nextNode, field); + assert.equal(nextNode, connection); }); test('skipsChildrenOfCollapsedBlocks', function () { this.blocks.buttonBlock.setCollapsed(true); @@ -546,9 +582,9 @@ suite('Navigation', function () { 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 end = this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getNextSibling(fieldStart); - assert.equal(nextNode.name, fieldEnd.name); + assert.equal(nextNode, end); }); }); @@ -575,7 +611,6 @@ suite('Navigation', function () { const prevNode = this.navigator.getPreviousSibling( this.blocks.fieldWithOutput, ); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; assert.equal(prevNode, [...this.blocks.statementInput1.getFields()][1]); }); test('fromNextToBlock', function () { @@ -616,14 +651,12 @@ suite('Navigation', function () { assert.isNull(prevNode); }); test('fromFieldToInput', function () { - const outputBlock = this.workspace.newBlock('field_input'); + const outputBlock = createRenderedBlock(this.workspace, 'field_input'); this.blocks.fieldAndInputs2.inputList[0].connection.connect( outputBlock.outputConnection, ); const field = this.blocks.fieldAndInputs2.inputList[1].fieldRow[0]; - const inputConnection = - this.blocks.fieldAndInputs2.inputList[0].connection; const prevNode = this.navigator.getPreviousSibling(field); assert.equal(prevNode, outputBlock); }); @@ -693,25 +726,20 @@ suite('Navigation', function () { 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 end = this.blocks.buttonBlock.getInput('STATEMENT1').connection; const nextNode = this.navigator.getPreviousSibling(fieldStart); - assert.equal(nextNode.name, fieldEnd.name); + assert.equal(nextNode, end); }); }); suite('In', function () { - setup(function () { - this.emptyWorkspace = Blockly.inject(document.createElement('div'), {}); - }); - teardown(function () { - workspaceTeardown.call(this, this.emptyWorkspace); - }); - test('fromInputToOutput', function () { + // The first child is the connected block since the connection itself + // cannot be navigated to directly. const input = this.blocks.statementInput1.inputList[0]; const inNode = this.navigator.getFirstChild(input.connection); - const outputConnection = this.blocks.fieldWithOutput.outputConnection; - assert.equal(inNode, outputConnection); + const connectedBlock = this.blocks.fieldWithOutput; + assert.equal(inNode, connectedBlock); }); test('fromInputToNull', function () { const input = this.blocks.statementInput2.inputList[0]; diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index dfbae3f0901..7cc99a80d4a 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -560,4 +560,363 @@ suite('Keyboard Shortcut Items', function () { ]), ); }); + + const blockJson = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_1', + 'x': 63, + 'y': 88, + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_1', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_forEach', + 'id': 'controls_forEach_1', + 'fields': { + 'VAR': { + 'id': '/wU7DoTDScBz~6hbq-[E', + }, + }, + 'inputs': { + 'LIST': { + 'block': { + 'type': 'lists_repeat', + 'id': 'lists_repeat_1', + 'inputs': { + 'ITEM': { + 'block': { + 'type': 'lists_getIndex', + 'id': 'lists_getIndex_1', + 'fields': { + 'MODE': 'GET', + 'WHERE': 'FROM_START', + }, + 'inputs': { + 'VALUE': { + 'block': { + 'type': 'variables_get', + 'id': 'Lhk_B9iVsV%BhhJ%h]m$', + 'fields': { + 'VAR': { + 'id': '.*~ZjUJ#Sua{h6xyVp7`', + }, + }, + }, + }, + }, + }, + }, + 'NUM': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_2', + 'fields': { + 'NUM': 5, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'controls_forEach', + 'id': 'controls_forEach_2', + 'x': 63, + 'y': 288, + 'fields': { + 'VAR': { + 'id': '+rcR|2HqfZ=vK}N8L{RU', + }, + }, + 'inputs': { + 'DO': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_2', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'math_number_3', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_1', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text', + 'id': 'text_1', + 'fields': { + 'TEXT': 'last block inside a loop', + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'text_print_2', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text', + 'id': 'text_2', + 'fields': { + 'TEXT': 'last block on workspace', + }, + }, + }, + }, + }, + }, + }, + ], + }, + }; + + suite('Jump shortcuts', function () { + setup(function () { + this.getFocusedNodeStub = sinon.stub( + Blockly.getFocusManager(), + 'getFocusedNode', + ); + this.focusNodeSpy = sinon.stub(Blockly.getFocusManager(), 'focusNode'); + Blockly.serialization.workspaces.load(blockJson, this.workspace); + }); + + test('Home focuses current block if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + }); + + test('Home focuses owning block if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + }); + + test('End focuses last input on owning block', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + const expectedFocus = inListBlock.getInput('AT').connection; + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('End has no effect if block has no inputs', function () { + const textBlock = this.workspace.getBlockById('text_1'); + this.getFocusedNodeStub.returns(textBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + + test('CtrlHome focuses top block in workspace if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlHome focuses top block in workspace if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlHome focuses top block in workspace if workspace is focused', function () { + this.getFocusedNodeStub.returns(this.workspace); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + + test('CtrlEnd focuses last block in workspace if block is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('CtrlEnd focuses last block in workspace if field is focused', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('CtrlEnd focuses last block in workspace if workspace is focused', function () { + this.getFocusedNodeStub.returns(this.workspace); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + + test('PageUp focuses on first block in stack', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), + ); + const expectedFocus = this.workspace.getBlockById('controls_repeat_1'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('PageDown focuses on last block in stack with nested row blocks', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const fieldToFocus = inListBlock.getField('MODE'); + this.getFocusedNodeStub.returns(fieldToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + const expectedFocus = this.workspace.getBlockById('math_number_2'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + test('PageDown focuses on last block in stack with many stack blocks', function () { + const blockToFocus = this.workspace.getBlockById('text_1'); + this.getFocusedNodeStub.returns(blockToFocus); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + const expectedFocus = this.workspace.getBlockById('text_2'); + sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + }); + + suite('in flyout', function () { + test('Home has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('End has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('CtrlHome focuses top block in flyout workspace', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const topBlock = this.workspace.getBlockById('controls_repeat_1'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, topBlock); + }); + test('CtrlEnd focuses last block in flyout workspace', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + const lastBlock = this.workspace.getBlockById('text_2'); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.END, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ); + sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + }); + test('PageUp has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + test('PageDown has no effect', function () { + this.workspace.internalIsFlyout = true; + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + this.getFocusedNodeStub.returns(inListBlock); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), + ); + sinon.assert.notCalled(this.focusNodeSpy); + }); + }); + }); }); diff --git a/tests/mocha/test_helpers/block_definitions.js b/tests/mocha/test_helpers/block_definitions.js index 26507b29cb8..e5ca106d2c4 100644 --- a/tests/mocha/test_helpers/block_definitions.js +++ b/tests/mocha/test_helpers/block_definitions.js @@ -196,8 +196,8 @@ export function createTestBlock() { }; } -export function createRenderedBlock(workspaceSvg, type) { - const block = workspaceSvg.newBlock(type); +export function createRenderedBlock(workspaceSvg, type, opt_id) { + const block = workspaceSvg.newBlock(type, opt_id); block.initSvg(); block.render(); return block; diff --git a/tsconfig.json b/tsconfig.json index f7b61f0a381..ff8353e64b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "moduleResolution": "node", "target": "ES2020", "strict": true, + "lib": ["esnext", "dom"], // This does not understand enums only used to define other enums, so we // cannot leave it enabled.