From 7462da7602fcdd31c4ada5b85eec8dca09c9fc16 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 29 Jul 2025 21:00:12 +0000 Subject: [PATCH 01/17] feat: Add initial support for screen readers. --- src/actions/mover.ts | 5 +++-- src/index.ts | 20 ++++++++++---------- src/keyboard_drag_strategy.ts | 4 ---- src/move_icon.ts | 10 ++++++++++ src/move_indicator.ts | 10 ++++++++++ test/index.html | 10 ++++++++++ 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/actions/mover.ts b/src/actions/mover.ts index dc67d317..27bacec8 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -154,6 +154,7 @@ export class Mover { workspace.setKeyboardMoveInProgress(true); const info = new MoveInfo(workspace, draggable, dragger, blurListener); this.moves.set(workspace, info); + workspace.setMovingBlock(draggable); // Begin drag. dragger.onDragStart(info.fakePointerEvent('pointerdown')); info.updateTotalDelta(); @@ -217,6 +218,7 @@ export class Mover { */ finishMove(workspace: WorkspaceSvg) { const info = this.preDragEndCleanup(workspace); + workspace.setMovingBlock(null); info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), @@ -244,12 +246,11 @@ export class Mover { this.patchDragger(info.dragger as dragging.Dragger, dragStrategy.moveType); // Save the position so we can put the cursor in a reasonable spot. - // @ts-expect-error Access to private property connectionCandidate. const target = dragStrategy.connectionCandidate?.neighbour; // Prevent the strategy connecting the block so we just delete one block. - // @ts-expect-error Access to private property connectionCandidate. dragStrategy.connectionCandidate = null; + workspace.setMovingBlock(null); info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), diff --git a/src/index.ts b/src/index.ts index 840f87d8..fb019075 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,16 +66,16 @@ export class KeyboardNavigation { workspace.addChangeListener(enableBlocksOnDrag); // Move the flyout for logical tab order. - const flyout = workspace.getFlyout(); - if (flyout != null && flyout instanceof Blockly.Flyout) { - // This relies on internals. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const flyoutElement = ((flyout as any).svgGroup_ as SVGElement) ?? null; - flyoutElement?.parentElement?.insertBefore( - flyoutElement, - workspace.getParentSvg(), - ); - } + // const flyout = workspace.getFlyout(); + // if (flyout != null && flyout instanceof Blockly.Flyout) { + // // This relies on internals. + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // const flyoutElement = ((flyout as any).svgGroup_ as SVGElement) ?? null; + // flyoutElement?.parentElement?.insertBefore( + // flyoutElement, + // workspace.getParentSvg(), + // ); + // } this.oldWorkspaceResize = workspace.resize; workspace.resize = () => { diff --git a/src/keyboard_drag_strategy.ts b/src/keyboard_drag_strategy.ts index 09b3bcc2..45b67d11 100644 --- a/src/keyboard_drag_strategy.ts +++ b/src/keyboard_drag_strategy.ts @@ -50,7 +50,6 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { // to the top left of the workspace. // @ts-expect-error block and startLoc are private. this.block.moveDuringDrag(this.startLoc); - // @ts-expect-error connectionCandidate is private. this.connectionCandidate = this.createInitialCandidate(); this.forceShowPreview(); this.block.addIcon(new MoveIcon(this.block)); @@ -62,9 +61,7 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { super.drag(newLoc); // Handle the case when an unconstrained drag found a connection candidate. - // @ts-expect-error connectionCandidate is private. if (this.connectionCandidate) { - // @ts-expect-error connectionCandidate is private. const neighbour = (this.connectionCandidate as ConnectionCandidate) .neighbour; // The next constrained move will resume the search from the current @@ -253,7 +250,6 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { private forceShowPreview() { // @ts-expect-error connectionPreviewer is private const previewer = this.connectionPreviewer; - // @ts-expect-error connectionCandidate is private const candidate = this.connectionCandidate as ConnectionCandidate; if (!candidate || !previewer) return; const block = this.block; diff --git a/src/move_icon.ts b/src/move_icon.ts index 3acd2ddf..a3956128 100644 --- a/src/move_icon.ts +++ b/src/move_icon.ts @@ -139,4 +139,14 @@ export class MoveIcon implements Blockly.IIcon, Blockly.IHasBubble { canBeFocused(): boolean { return false; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): Blockly.utils.aria.Role | null { + throw new Error('This node is not focusable.'); + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + throw new Error('This node is not focusable.'); + } } diff --git a/src/move_indicator.ts b/src/move_indicator.ts index e6f8e92b..090e0a21 100644 --- a/src/move_indicator.ts +++ b/src/move_indicator.ts @@ -172,4 +172,14 @@ export class MoveIndicatorBubble canBeFocused(): boolean { return false; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): Blockly.utils.aria.Role | null { + throw new Error('This node is not focusable.'); + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + throw new Error('This node is not focusable.'); + } } diff --git a/test/index.html b/test/index.html index 212d0354..6232f5a3 100644 --- a/test/index.html +++ b/test/index.html @@ -134,6 +134,16 @@ +
+
+ Instructions for screen reader support: + + For the primary discussion around screen reader support, please read and comment on: discussion #673. +
From 969b62a9639d5a72c0238dcade6b0475c689b6e0 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 30 Jul 2025 23:41:27 +0000 Subject: [PATCH 02/17] chore: Restore TS directives. The branch is no longer dependent on Core changes. Also, expose state that may be needed for experimentation in mover. --- src/actions/mover.ts | 10 +++++++--- src/keyboard_drag_strategy.ts | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/actions/mover.ts b/src/actions/mover.ts index 4f0f3dc9..36b70f28 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -82,6 +82,8 @@ export class Mover { private moveIndicator?: MoveIndicatorBubble; + private movingBlock: (IDraggable & IFocusableNode & IBoundedElement & ISelectable) | null = null; + constructor(protected navigation: Navigation) {} /** @@ -154,7 +156,7 @@ export class Mover { workspace.setKeyboardMoveInProgress(true); const info = new MoveInfo(workspace, draggable, dragger, blurListener); this.moves.set(workspace, info); - workspace.setMovingBlock(draggable); + this.movingBlock = draggable; // Begin drag. dragger.onDragStart(info.fakePointerEvent('pointerdown')); info.updateTotalDelta(); @@ -218,7 +220,7 @@ export class Mover { */ finishMove(workspace: WorkspaceSvg) { const info = this.preDragEndCleanup(workspace); - workspace.setMovingBlock(null); + this.movingBlock = null; info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), @@ -246,11 +248,13 @@ export class Mover { this.patchDragger(info.dragger as dragging.Dragger, dragStrategy.moveType); // Save the position so we can put the cursor in a reasonable spot. + // @ts-expect-error Access to private property connectionCandidate. const target = dragStrategy.connectionCandidate?.neighbour; // Prevent the strategy connecting the block so we just delete one block. + // @ts-expect-error Access to private property connectionCandidate. dragStrategy.connectionCandidate = null; - workspace.setMovingBlock(null); + this.movingBlock = null; info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), diff --git a/src/keyboard_drag_strategy.ts b/src/keyboard_drag_strategy.ts index 45b67d11..09b3bcc2 100644 --- a/src/keyboard_drag_strategy.ts +++ b/src/keyboard_drag_strategy.ts @@ -50,6 +50,7 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { // to the top left of the workspace. // @ts-expect-error block and startLoc are private. this.block.moveDuringDrag(this.startLoc); + // @ts-expect-error connectionCandidate is private. this.connectionCandidate = this.createInitialCandidate(); this.forceShowPreview(); this.block.addIcon(new MoveIcon(this.block)); @@ -61,7 +62,9 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { super.drag(newLoc); // Handle the case when an unconstrained drag found a connection candidate. + // @ts-expect-error connectionCandidate is private. if (this.connectionCandidate) { + // @ts-expect-error connectionCandidate is private. const neighbour = (this.connectionCandidate as ConnectionCandidate) .neighbour; // The next constrained move will resume the search from the current @@ -250,6 +253,7 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { private forceShowPreview() { // @ts-expect-error connectionPreviewer is private const previewer = this.connectionPreviewer; + // @ts-expect-error connectionCandidate is private const candidate = this.connectionCandidate as ConnectionCandidate; if (!candidate || !previewer) return; const block = this.block; From e109f0a76599086f46d05403d1aa419d24dae66c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 30 Jul 2025 23:42:24 +0000 Subject: [PATCH 03/17] feat: Add initial screen reader support. This is in the form of a bunch of monkey patches that carefully replace essentially all ARIA initialization happening within core Blockly. This is largely done in a way that won't be well compatible with certain uses of Blockly itself, and is only mean to act as a baseline for testing. --- src/aria.ts | 108 +++++++++ src/aria_monkey_patches.js | 426 +++++++++++++++++++++++++++++++++++ src/navigation_controller.ts | 3 + test/index.html | 8 + test/index.ts | 11 + 5 files changed, 556 insertions(+) create mode 100644 src/aria.ts create mode 100644 src/aria_monkey_patches.js diff --git a/src/aria.ts b/src/aria.ts new file mode 100644 index 00000000..f86886cc --- /dev/null +++ b/src/aria.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const ARIA_PREFIX = 'aria-'; +const ROLE_ATTRIBUTE = 'role'; + +// TODO: Finalize this. +export enum Role { + GRID = 'grid', + GRIDCELL = 'gridcell', + GROUP = 'group', + LISTBOX = 'listbox', + MENU = 'menu', + MENUITEM = 'menuitem', + MENUITEMCHECKBOX = 'menuitemcheckbox', + OPTION = 'option', + PRESENTATION = 'presentation', + ROW = 'row', + TREE = 'tree', + TREEITEM = 'treeitem', + SEPARATOR = 'separator', + STATUS = 'status', + REGION = 'region', + IMAGE = 'image', + FIGURE = 'figure', + BUTTON = 'button', + CHECKBOX = 'checkbox', + TEXTBOX = 'textbox', + APPLICATION = 'application', +} + +// TODO: Finalize this. +export enum State { + ACTIVEDESCENDANT = 'activedescendant', + COLCOUNT = 'colcount', + DISABLED = 'disabled', + EXPANDED = 'expanded', + INVALID = 'invalid', + LABEL = 'label', + LABELLEDBY = 'labelledby', + LEVEL = 'level', + ORIENTATION = 'orientation', + POSINSET = 'posinset', + ROWCOUNT = 'rowcount', + SELECTED = 'selected', + SETSIZE = 'setsize', + VALUEMAX = 'valuemax', + VALUEMIN = 'valuemin', + LIVE = 'live', + HIDDEN = 'hidden', + ROLEDESCRIPTION = 'roledescription', + ATOMIC = 'atomic', + OWNS = 'owns', +} + +var isMutatingAriaProperty: boolean = false; + +export function setRole(element: Element, roleName: Role | null) { + isMutatingAriaProperty = true; + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else element.removeAttribute(ROLE_ATTRIBUTE); + isMutatingAriaProperty = false; +} + +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; +} + +export function setState( + element: Element, + stateName: State, + value: string | boolean | number | string[], +) { + isMutatingAriaProperty = true; + if (Array.isArray(value)) { + value = value.join(' '); + } + const attrStateName = ARIA_PREFIX + stateName; + element.setAttribute(attrStateName, `${value}`); + isMutatingAriaProperty = false; +} + +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + return element.getAttribute(attrStateName); +} + +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; +} + +export function isCurrentlyMutatingAriaProperty(): boolean { + return isMutatingAriaProperty; +} diff --git a/src/aria_monkey_patches.js b/src/aria_monkey_patches.js new file mode 100644 index 00000000..1f9eb955 --- /dev/null +++ b/src/aria_monkey_patches.js @@ -0,0 +1,426 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Overrides a bunch of methods throughout core Blockly in order + * to augment Blockly components with ARIA support. + */ + +// TODO: Consider splitting this up into multiple files (might make things a bit easier). + +import * as Blockly from 'blockly/core'; +import * as aria from './aria'; + +const oldCreateElementNS = document.createElementNS; + +document.createElementNS = function(namepspaceURI, qualifiedName) { + const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName); + // Top-level SVG elements and groups are presentation by default. They will be + // specified more specifically elsewhere if they need to be readable. + if (qualifiedName === 'svg' || qualifiedName === 'g') { + aria.setRole(element, aria.Role.PRESENTATION); + } + return element; +}; + +const oldElementSetAttribute = Element.prototype.setAttribute; + +Element.prototype.setAttribute = function(name, value) { + // This is a hacky way to disable all aria changes in core Blockly since it's + // easier to just undefine everything globally and then conditionally reenable + // things with the correct definitions. + if (aria.isCurrentlyMutatingAriaProperty() || (name !== 'role' && !name.startsWith('aria-'))) { + oldElementSetAttribute.call(this, name, value); + } +}; + +const oldIconInitView = Blockly.icons.Icon.prototype.initView; + +Blockly.icons.Icon.prototype.initView = function(pointerdownListener) { + oldIconInitView.call(this, pointerdownListener); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Icon'); +}; + +const oldCommentIconInitView = Blockly.icons.CommentIcon.prototype.initView; + +Blockly.icons.CommentIcon.prototype.initView = function(pointerdownListener) { + oldCommentIconInitView.call(this, pointerdownListener); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment'); +}; + +const oldMutatorIconInitView = Blockly.icons.MutatorIcon.prototype.initView; + +Blockly.icons.MutatorIcon.prototype.initView = function(pointerdownListener) { + oldMutatorIconInitView.call(this, pointerdownListener); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator'); +}; + +const oldWarningIconInitView = Blockly.icons.WarningIcon.prototype.initView; + +Blockly.icons.WarningIcon.prototype.initView = function(pointerdownListener) { + oldWarningIconInitView.call(this, pointerdownListener); + const element = this.getFocusableElement(); + aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning'); +}; + +const oldFieldCreateTextElement = Blockly.Field.prototype.createTextElement_; + +Blockly.Field.prototype.createTextElement_ = function() { + oldFieldCreateTextElement.call(this); + // The text itself is presentation since it's represented through the + // block's ARIA label. + aria.setState(this.getTextElement(), aria.State.HIDDEN, true); +}; + +// TODO: This can be consolidated to FieldInput, but that's not exported so it has to be overwritten on a per-field basis. +const oldFieldNumberInit = Blockly.FieldNumber.prototype.init; +const oldFieldTextInputInit = Blockly.FieldTextInput.prototype.init; + +Blockly.FieldNumber.prototype.init = function() { + oldFieldNumberInit.call(this); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, this.name ? `Text ${this.name}` : 'Text'); +}; + +Blockly.FieldTextInput.prototype.init = function() { + oldFieldTextInputInit.call(this); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, this.name ? `Text ${this.name}` : 'Text'); +}; + +const oldFieldLabelInitView = Blockly.FieldLabel.prototype.initView; + +Blockly.FieldLabel.prototype.initView = function() { + oldFieldLabelInitView.call(this); + // There's no additional semantic meaning needed for a label; the aria-label + // should be sufficient for context. + aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText()); +}; + +const oldFieldImageInitView = Blockly.FieldImage.prototype.initView; + +Blockly.FieldImage.prototype.initView = function() { + oldFieldImageInitView.call(this); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.IMAGE); + aria.setState(element, aria.State.LABEL, this.name ? `Image ${this.name}` : 'Image'); +}; + +const oldFieldDropdownInitView = Blockly.FieldDropdown.prototype.initView; + +Blockly.FieldDropdown.prototype.initView = function() { + oldFieldDropdownInitView.call(this); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.LISTBOX); + aria.setState(element, aria.State.LABEL, this.name ? `Item ${this.name}` : 'Item'); +}; + +const oldFieldCheckboxInitView = Blockly.FieldCheckbox.prototype.initView; + +Blockly.FieldCheckbox.prototype.initView = function() { + oldFieldCheckboxInitView.call(this); + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState(element, aria.State.LABEL, this.name ? `Checkbox ${this.name}` : 'Checkbox'); +}; + +const oldFlyoutButtonUpdateTransform = Blockly.FlyoutButton.prototype.updateTransform; + +Blockly.FlyoutButton.prototype.updateTransform = function() { + // This is a very hacky way to augment FlyoutButton's initialization since it + // happens in FlyoutButton's constructor (which can't be patched directly). + if (!this.isPatchInitialized) { + this.isPatchInitialized = true; + oldFlyoutButtonUpdateTransform.call(this); + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, 'Button'); + } +}; + +const oldRenderedWorkspaceCommentAddModelUpdateBindings = Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings; + +Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings = function() { + // This is a very hacky way to augment RenderedWorkspaceComments's + // initialization since it happens in RenderedWorkspaceComments's constructor + // (which can't be patched directly). + if (!this.isPatchInitialized) { + this.isPatchInitialized = true; + oldRenderedWorkspaceCommentAddModelUpdateBindings.call(this); + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); + } +}; + +const oldRenderedConnectionFindHighlightSvg = Blockly.RenderedConnection.prototype.findHighlightSvg; + +Blockly.RenderedConnection.prototype.findHighlightSvg = function() { + const element = oldRenderedConnectionFindHighlightSvg.call(this); + // This is a later initialization than most components but it's likely + // adequate since the creation of RenderedConnection's focusable element is + // part of the block rendering lifecycle (so the class itself isn't even aware + // when its element exists). + if (element) { + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Open connection'); + } + return element; +}; + +const oldWorkspaceSvgCreateDom = Blockly.WorkspaceSvg.prototype.createDom; + +Blockly.WorkspaceSvg.prototype.createDom = function(backgroundClass, injectionDiv) { + const element = oldWorkspaceSvgCreateDom.call(this, backgroundClass, injectionDiv); + aria.setRole(element, aria.Role.TREE); + let ariaLabel = null; + if (this.injectionDiv) { + ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; + } else if (this.isFlyout) { + ariaLabel = 'Flyout'; + } else if (this.isMutator) { + ariaLabel = 'Mutator'; + } else { + throw new Error('Cannot determine ARIA label for workspace.'); + } + aria.setState(element, aria.State.LABEL, ariaLabel); + return element; +}; + +const oldToolboxCreateDom = Blockly.Toolbox.prototype.createDom_; + +Blockly.Toolbox.prototype.createDom_ = function(workspace) { + const element = oldToolboxCreateDom.call(this, workspace); + aria.setRole(element, aria.Role.TREE); + return element; +}; + +const recomputeAriaOwnersInToolbox = function(toolbox) { + const focusable = toolbox.getFocusableElement(); + const selectableChildren = toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildElems = selectableChildren.map((selectable) => selectable.getFocusableElement()); + const focusableChildIds = focusableChildElems.map((elem) => elem.id); + aria.setState(focusable, aria.State.OWNS, [... new Set(focusableChildIds)].join(' ')); + // Ensure children have the correct position set. + // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. + focusableChildElems.forEach((elem, index) => aria.setState(elem, aria.State.POSINSET, index + 1)); +} + +// TODO: Reimplement selected for items and expanded for categories, and levels. +const oldToolboxCategoryInit = Blockly.ToolboxCategory.prototype.init; + +Blockly.ToolboxCategory.prototype.init = function() { + oldToolboxCategoryInit.call(this); + aria.setRole(this.getFocusableElement(), aria.Role.TREEITEM); + recomputeAriaOwnersInToolbox(this.parentToolbox_); +}; + +const oldCollapsibleToolboxCategoryInit = Blockly.CollapsibleToolboxCategory.prototype.init; + +Blockly.CollapsibleToolboxCategory.prototype.init = function() { + oldCollapsibleToolboxCategoryInit.call(this); + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.GROUP); + + // Ensure this group has properly set children. + const selectableChildren = this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map((selectable) => selectable.getFocusableElement().id); + aria.setState(element, aria.State.OWNS, [... new Set(focusableChildIds)].join(' ')); + recomputeAriaOwnersInToolbox(this.parentToolbox_); +}; + +const oldToolboxSeparatorInit = Blockly.ToolboxSeparator.prototype.init; + +Blockly.ToolboxSeparator.prototype.init = function() { + oldToolboxSeparatorInit.call(this); + aria.setRole(this.getFocusableElement(), aria.Role.SEPARATOR); + recomputeAriaOwnersInToolbox(this.parentToolbox_); +}; + +const oldBlockSvgDoInit = Blockly.BlockSvg.prototype.doInit_; +const oldBlockSvgSetParent = Blockly.BlockSvg.prototype.setParent; +const oldBlockSvgStartDrag = Blockly.BlockSvg.prototype.startDrag; +const oldBlockSvgDrag = Blockly.BlockSvg.prototype.drag; +const oldBlockSvgEndDrag = Blockly.BlockSvg.prototype.endDrag; +const oldBlockSvgRevertDrag = Blockly.BlockSvg.prototype.revertDrag; +const oldBlockSvgOnNodeFocus = Blockly.BlockSvg.prototype.onNodeFocus; +const oldBlockSvgOnNodeBlur = Blockly.BlockSvg.prototype.onNodeBlur; + +const computeBlockAriaLabel = function(block) { + // Guess the block's aria label based on its field labels. + if (block.isShadow()) { + // TODO: Shadows may have more than one field. + // Shadow blocks are best represented directly by their field since they + // effectively operate like a field does for keyboard navigation purposes. + const field = Array.from(block.getFields())[0]; + return aria.getState(field.getFocusableElement(), aria.State.LABEL); + } + + const fieldLabels = []; + for (const field of block.getFields()) { + if (field instanceof Blockly.FieldLabel) { + fieldLabels.push(field.getText()); + } + } + return fieldLabels.join(' '); +}; + +const collectSiblingBlocksForBlock = function(block, surroundParent) { + // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The + // returned list needs to be relatively stable for consistency block indexes + // read out to users via screen readers. + if (surroundParent) { + // Start from the first sibling and iterate in navigation order. + const firstSibling = surroundParent.getChildren(false)[0]; + // TODO: Fix this case. It happens when replacing a block input with a new block. + if (!firstSibling) throw new Error('No child in parent (somehow).'); + const siblings = [firstSibling]; + let nextSibling = firstSibling; + while (nextSibling = nextSibling.getNextBlock()) { + siblings.push(nextSibling); + } + return siblings; + } else { + // For top-level blocks, simply return those from the workspace. + return block.workspace.getTopBlocks(false); + } +} + +const computeLevelInWorkspaceForBlock = function(block) { + const surroundParent = block.getSurroundParent(); + return surroundParent ? computeLevelInWorkspaceForBlock(surroundParent) + 1 : 0; +} + +// TODO: Do this efficiently (probably centrally). +const recomputeAriaTreeItemDetailsInBlockRecursively = function(block) { + const elem = block.getFocusableElement(); + const connection = block.currentConnectionCandidate; + let childPosition; + let parentsChildCount; + let hierarchyDepth; + if (connection) { + // If the block is being inserted into a new location, the position is hypothetical. + // TODO: Figure out how to deal with output connections. + let surroundParent; + let siblingBlocks; + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { + surroundParent = connection.sourceBlock_; + siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); + // The block is being added as a child since it's input. + // TODO: Figure out how to compute the correct position. + childPosition = 1; + } else { + surroundParent = connection.sourceBlock_.getSurroundParent(); + siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); + // The block is being added after the connected block. + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; + } + parentsChildCount = siblingBlocks.length + 1; + hierarchyDepth = surroundParent ? computeLevelInWorkspaceForBlock(surroundParent) + 1 : 1; + } else { + const surroundParent = block.getSurroundParent(); + const siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); + childPosition = siblingBlocks.indexOf(block) + 1; + parentsChildCount = siblingBlocks.length; + hierarchyDepth = computeLevelInWorkspaceForBlock(block) + 1; + } + aria.setState(elem, aria.State.POSINSET, childPosition); + aria.setState(elem, aria.State.SETSIZE, parentsChildCount); + aria.setState(elem, aria.State.LEVEL, hierarchyDepth); + block.getChildren(false).forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); +}; + +const announceDynamicAriaStateForBlock = function(block, isMoving, isCanceled, newLoc) { + const connection = block.currentConnectionCandidate; + if (isCanceled) { + aria.announceDynamicAriaState('Canceled movement') + return; + } + if (!isMoving) return; + if (connection) { + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + let surroundParent = connection.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { + announcementContext.push('to','input','of'); + } else { + announcementContext.push('to','child','of'); + } + + announcementContext.push(computeBlockAriaLabel(surroundParent)); + + // 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. + aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`); + } +} + +Blockly.BlockSvg.prototype.doInit_ = function() { + oldBlockSvgDoInit.call(this); + const svgPath = this.getFocusableElement(); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, aria.Role.TREEITEM); + aria.setState(svgPath, aria.State.LABEL, computeBlockAriaLabel(this)); + svgPath.tabIndex = -1; + this.currentConnectionCandidate = null; +}; + +Blockly.BlockSvg.prototype.setParent = function(newParent) { + oldBlockSvgSetParent.call(this, newParent); + this.workspace.getTopBlocks(false).forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); +}; + +Blockly.BlockSvg.prototype.startDrag = function(e) { + oldBlockSvgStartDrag.call(this, e); + this.currentConnectionCandidate = this.dragStrategy.connectionCandidate?.neighbour ?? null; + announceDynamicAriaStateForBlock(this, true, false); +}; + +Blockly.BlockSvg.prototype.drag = function(newLoc, e) { + oldBlockSvgDrag.call(this, newLoc, e); + this.currentConnectionCandidate = this.dragStrategy.connectionCandidate?.neighbour ?? null; + announceDynamicAriaStateForBlock(this, true, false, newLoc); +}; + +Blockly.BlockSvg.prototype.endDrag = function(e) { + oldBlockSvgEndDrag.call(this, e); + this.currentConnectionCandidate = null; + announceDynamicAriaStateForBlock(this, false, false); +}; + +Blockly.BlockSvg.prototype.revertDrag = function() { + oldBlockSvgRevertDrag.call(this); + announceDynamicAriaStateForBlock(this, false, true); +}; + +Blockly.BlockSvg.prototype.onNodeFocus = function() { + oldBlockSvgOnNodeFocus.call(this); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); +} + +Blockly.BlockSvg.prototype.onNodeBlur = function() { + aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); + oldBlockSvgOnNodeBlur.call(this); +}; + +// TODO: Figure out how to patch CommentEditor. It doesn't seem to have any methods really to override, so it may actually require patching at the dom utility layer, or higher up. +// TODO: Ditto for CommentBarButton and its children. +// TODO: Ditto for Bubble and its children. diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index de53ade4..7843c2dc 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -36,6 +36,9 @@ import {Mover} from './actions/mover'; import {DuplicateAction} from './actions/duplicate'; import {StackNavigationAction} from './actions/stack_navigation'; +// TODO: Implement unregistration. +import './aria_monkey_patches'; + const KeyCodes = BlocklyUtils.KeyCodes; /** diff --git a/test/index.html b/test/index.html index 6232f5a3..a5492924 100644 --- a/test/index.html +++ b/test/index.html @@ -85,6 +85,14 @@ thead { font-weight: bold; } + + #blocklyAriaAnnounce { + position: absolute; + left: -9999px; + width: 1px; + height: px; + overflow: hidden; + } diff --git a/test/index.ts b/test/index.ts index 4aa282a8..4d1b1039 100644 --- a/test/index.ts +++ b/test/index.ts @@ -24,6 +24,7 @@ import {javascriptGenerator} from 'blockly/javascript'; // @ts-expect-error No types in js file import {load} from './loadTestBlocks'; import {runCode, registerRunCodeShortcut} from './runCode'; +import * as aria from '../src/aria'; (window as unknown as {Blockly: typeof Blockly}).Blockly = Blockly; @@ -98,6 +99,16 @@ function createWorkspace(): Blockly.WorkspaceSvg { registerNavigationDeferringToolbox(); const workspace = Blockly.inject(blocklyDiv, injectOptions); + const injectionDiv = document.querySelector('.injectionDiv'); + if (!injectionDiv) { + throw new Error('Expected injection div to exist after injection.'); + } + // See: https://stackoverflow.com/a/48590836 for a reference. + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + injectionDiv.appendChild(ariaAnnouncementSpan); + Blockly.ContextMenuItems.registerCommentOptions(); new KeyboardNavigation(workspace); registerRunCodeShortcut(); From 17d17b99ee6b5353062567dc4ce63baac37ad5da Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 30 Jul 2025 23:44:32 +0000 Subject: [PATCH 04/17] chore: Lint fixes. --- src/actions/mover.ts | 4 +- src/aria.ts | 2 +- src/aria_monkey_patches.js | 252 ++++++++++++++++++++++++------------- test/index.html | 26 +++- 4 files changed, 192 insertions(+), 92 deletions(-) diff --git a/src/actions/mover.ts b/src/actions/mover.ts index 36b70f28..cf986ab5 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -82,7 +82,9 @@ export class Mover { private moveIndicator?: MoveIndicatorBubble; - private movingBlock: (IDraggable & IFocusableNode & IBoundedElement & ISelectable) | null = null; + private movingBlock: + | (IDraggable & IFocusableNode & IBoundedElement & ISelectable) + | null = null; constructor(protected navigation: Navigation) {} diff --git a/src/aria.ts b/src/aria.ts index f86886cc..016a3d3d 100644 --- a/src/aria.ts +++ b/src/aria.ts @@ -56,7 +56,7 @@ export enum State { OWNS = 'owns', } -var isMutatingAriaProperty: boolean = false; +let isMutatingAriaProperty = false; export function setRole(element: Element, roleName: Role | null) { isMutatingAriaProperty = true; diff --git a/src/aria_monkey_patches.js b/src/aria_monkey_patches.js index 1f9eb955..39e2ee32 100644 --- a/src/aria_monkey_patches.js +++ b/src/aria_monkey_patches.js @@ -16,7 +16,7 @@ import * as aria from './aria'; const oldCreateElementNS = document.createElementNS; -document.createElementNS = function(namepspaceURI, qualifiedName) { +document.createElementNS = function (namepspaceURI, qualifiedName) { const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName); // Top-level SVG elements and groups are presentation by default. They will be // specified more specifically elsewhere if they need to be readable. @@ -28,18 +28,21 @@ document.createElementNS = function(namepspaceURI, qualifiedName) { const oldElementSetAttribute = Element.prototype.setAttribute; -Element.prototype.setAttribute = function(name, value) { +Element.prototype.setAttribute = function (name, value) { // This is a hacky way to disable all aria changes in core Blockly since it's // easier to just undefine everything globally and then conditionally reenable // things with the correct definitions. - if (aria.isCurrentlyMutatingAriaProperty() || (name !== 'role' && !name.startsWith('aria-'))) { + if ( + aria.isCurrentlyMutatingAriaProperty() || + (name !== 'role' && !name.startsWith('aria-')) + ) { oldElementSetAttribute.call(this, name, value); } }; const oldIconInitView = Blockly.icons.Icon.prototype.initView; -Blockly.icons.Icon.prototype.initView = function(pointerdownListener) { +Blockly.icons.Icon.prototype.initView = function (pointerdownListener) { oldIconInitView.call(this, pointerdownListener); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.FIGURE); @@ -48,31 +51,43 @@ Blockly.icons.Icon.prototype.initView = function(pointerdownListener) { const oldCommentIconInitView = Blockly.icons.CommentIcon.prototype.initView; -Blockly.icons.CommentIcon.prototype.initView = function(pointerdownListener) { +Blockly.icons.CommentIcon.prototype.initView = function (pointerdownListener) { oldCommentIconInitView.call(this, pointerdownListener); const element = this.getFocusableElement(); - aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment'); + aria.setState( + element, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); }; const oldMutatorIconInitView = Blockly.icons.MutatorIcon.prototype.initView; -Blockly.icons.MutatorIcon.prototype.initView = function(pointerdownListener) { +Blockly.icons.MutatorIcon.prototype.initView = function (pointerdownListener) { oldMutatorIconInitView.call(this, pointerdownListener); const element = this.getFocusableElement(); - aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator'); + aria.setState( + element, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); }; const oldWarningIconInitView = Blockly.icons.WarningIcon.prototype.initView; -Blockly.icons.WarningIcon.prototype.initView = function(pointerdownListener) { +Blockly.icons.WarningIcon.prototype.initView = function (pointerdownListener) { oldWarningIconInitView.call(this, pointerdownListener); const element = this.getFocusableElement(); - aria.setState(element, aria.State.LABEL, this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning'); + aria.setState( + element, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); }; const oldFieldCreateTextElement = Blockly.Field.prototype.createTextElement_; -Blockly.Field.prototype.createTextElement_ = function() { +Blockly.Field.prototype.createTextElement_ = function () { oldFieldCreateTextElement.call(this); // The text itself is presentation since it's represented through the // block's ARIA label. @@ -83,23 +98,31 @@ Blockly.Field.prototype.createTextElement_ = function() { const oldFieldNumberInit = Blockly.FieldNumber.prototype.init; const oldFieldTextInputInit = Blockly.FieldTextInput.prototype.init; -Blockly.FieldNumber.prototype.init = function() { +Blockly.FieldNumber.prototype.init = function () { oldFieldNumberInit.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, this.name ? `Text ${this.name}` : 'Text'); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Text ${this.name}` : 'Text', + ); }; -Blockly.FieldTextInput.prototype.init = function() { +Blockly.FieldTextInput.prototype.init = function () { oldFieldTextInputInit.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, this.name ? `Text ${this.name}` : 'Text'); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Text ${this.name}` : 'Text', + ); }; const oldFieldLabelInitView = Blockly.FieldLabel.prototype.initView; -Blockly.FieldLabel.prototype.initView = function() { +Blockly.FieldLabel.prototype.initView = function () { oldFieldLabelInitView.call(this); // There's no additional semantic meaning needed for a label; the aria-label // should be sufficient for context. @@ -108,34 +131,47 @@ Blockly.FieldLabel.prototype.initView = function() { const oldFieldImageInitView = Blockly.FieldImage.prototype.initView; -Blockly.FieldImage.prototype.initView = function() { +Blockly.FieldImage.prototype.initView = function () { oldFieldImageInitView.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.IMAGE); - aria.setState(element, aria.State.LABEL, this.name ? `Image ${this.name}` : 'Image'); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Image ${this.name}` : 'Image', + ); }; const oldFieldDropdownInitView = Blockly.FieldDropdown.prototype.initView; -Blockly.FieldDropdown.prototype.initView = function() { +Blockly.FieldDropdown.prototype.initView = function () { oldFieldDropdownInitView.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.LISTBOX); - aria.setState(element, aria.State.LABEL, this.name ? `Item ${this.name}` : 'Item'); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Item ${this.name}` : 'Item', + ); }; const oldFieldCheckboxInitView = Blockly.FieldCheckbox.prototype.initView; -Blockly.FieldCheckbox.prototype.initView = function() { +Blockly.FieldCheckbox.prototype.initView = function () { oldFieldCheckboxInitView.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.CHECKBOX); - aria.setState(element, aria.State.LABEL, this.name ? `Checkbox ${this.name}` : 'Checkbox'); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Checkbox ${this.name}` : 'Checkbox', + ); }; -const oldFlyoutButtonUpdateTransform = Blockly.FlyoutButton.prototype.updateTransform; +const oldFlyoutButtonUpdateTransform = + Blockly.FlyoutButton.prototype.updateTransform; -Blockly.FlyoutButton.prototype.updateTransform = function() { +Blockly.FlyoutButton.prototype.updateTransform = function () { // This is a very hacky way to augment FlyoutButton's initialization since it // happens in FlyoutButton's constructor (which can't be patched directly). if (!this.isPatchInitialized) { @@ -148,25 +184,28 @@ Blockly.FlyoutButton.prototype.updateTransform = function() { } }; -const oldRenderedWorkspaceCommentAddModelUpdateBindings = Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings; - -Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings = function() { - // This is a very hacky way to augment RenderedWorkspaceComments's - // initialization since it happens in RenderedWorkspaceComments's constructor - // (which can't be patched directly). - if (!this.isPatchInitialized) { - this.isPatchInitialized = true; - oldRenderedWorkspaceCommentAddModelUpdateBindings.call(this); - - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); - } -}; +const oldRenderedWorkspaceCommentAddModelUpdateBindings = + Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings; + +Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings = + function () { + // This is a very hacky way to augment RenderedWorkspaceComments's + // initialization since it happens in RenderedWorkspaceComments's constructor + // (which can't be patched directly). + if (!this.isPatchInitialized) { + this.isPatchInitialized = true; + oldRenderedWorkspaceCommentAddModelUpdateBindings.call(this); + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); + } + }; -const oldRenderedConnectionFindHighlightSvg = Blockly.RenderedConnection.prototype.findHighlightSvg; +const oldRenderedConnectionFindHighlightSvg = + Blockly.RenderedConnection.prototype.findHighlightSvg; -Blockly.RenderedConnection.prototype.findHighlightSvg = function() { +Blockly.RenderedConnection.prototype.findHighlightSvg = function () { const element = oldRenderedConnectionFindHighlightSvg.call(this); // This is a later initialization than most components but it's likely // adequate since the creation of RenderedConnection's focusable element is @@ -181,8 +220,15 @@ Blockly.RenderedConnection.prototype.findHighlightSvg = function() { const oldWorkspaceSvgCreateDom = Blockly.WorkspaceSvg.prototype.createDom; -Blockly.WorkspaceSvg.prototype.createDom = function(backgroundClass, injectionDiv) { - const element = oldWorkspaceSvgCreateDom.call(this, backgroundClass, injectionDiv); +Blockly.WorkspaceSvg.prototype.createDom = function ( + backgroundClass, + injectionDiv, +) { + const element = oldWorkspaceSvgCreateDom.call( + this, + backgroundClass, + injectionDiv, + ); aria.setRole(element, aria.Role.TREE); let ariaLabel = null; if (this.injectionDiv) { @@ -200,50 +246,67 @@ Blockly.WorkspaceSvg.prototype.createDom = function(backgroundClass, injectionDi const oldToolboxCreateDom = Blockly.Toolbox.prototype.createDom_; -Blockly.Toolbox.prototype.createDom_ = function(workspace) { +Blockly.Toolbox.prototype.createDom_ = function (workspace) { const element = oldToolboxCreateDom.call(this, workspace); aria.setRole(element, aria.Role.TREE); return element; }; -const recomputeAriaOwnersInToolbox = function(toolbox) { +const recomputeAriaOwnersInToolbox = function (toolbox) { const focusable = toolbox.getFocusableElement(); - const selectableChildren = toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildElems = selectableChildren.map((selectable) => selectable.getFocusableElement()); + const selectableChildren = + toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildElems = selectableChildren.map((selectable) => + selectable.getFocusableElement(), + ); const focusableChildIds = focusableChildElems.map((elem) => elem.id); - aria.setState(focusable, aria.State.OWNS, [... new Set(focusableChildIds)].join(' ')); + aria.setState( + focusable, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); // Ensure children have the correct position set. // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. - focusableChildElems.forEach((elem, index) => aria.setState(elem, aria.State.POSINSET, index + 1)); -} + focusableChildElems.forEach((elem, index) => + aria.setState(elem, aria.State.POSINSET, index + 1), + ); +}; // TODO: Reimplement selected for items and expanded for categories, and levels. const oldToolboxCategoryInit = Blockly.ToolboxCategory.prototype.init; -Blockly.ToolboxCategory.prototype.init = function() { +Blockly.ToolboxCategory.prototype.init = function () { oldToolboxCategoryInit.call(this); aria.setRole(this.getFocusableElement(), aria.Role.TREEITEM); recomputeAriaOwnersInToolbox(this.parentToolbox_); }; -const oldCollapsibleToolboxCategoryInit = Blockly.CollapsibleToolboxCategory.prototype.init; +const oldCollapsibleToolboxCategoryInit = + Blockly.CollapsibleToolboxCategory.prototype.init; -Blockly.CollapsibleToolboxCategory.prototype.init = function() { +Blockly.CollapsibleToolboxCategory.prototype.init = function () { oldCollapsibleToolboxCategoryInit.call(this); const element = this.getFocusableElement(); aria.setRole(element, aria.Role.GROUP); // Ensure this group has properly set children. - const selectableChildren = this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildIds = selectableChildren.map((selectable) => selectable.getFocusableElement().id); - aria.setState(element, aria.State.OWNS, [... new Set(focusableChildIds)].join(' ')); + const selectableChildren = + this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map( + (selectable) => selectable.getFocusableElement().id, + ); + aria.setState( + element, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); recomputeAriaOwnersInToolbox(this.parentToolbox_); }; const oldToolboxSeparatorInit = Blockly.ToolboxSeparator.prototype.init; -Blockly.ToolboxSeparator.prototype.init = function() { +Blockly.ToolboxSeparator.prototype.init = function () { oldToolboxSeparatorInit.call(this); aria.setRole(this.getFocusableElement(), aria.Role.SEPARATOR); recomputeAriaOwnersInToolbox(this.parentToolbox_); @@ -258,7 +321,7 @@ const oldBlockSvgRevertDrag = Blockly.BlockSvg.prototype.revertDrag; const oldBlockSvgOnNodeFocus = Blockly.BlockSvg.prototype.onNodeFocus; const oldBlockSvgOnNodeBlur = Blockly.BlockSvg.prototype.onNodeBlur; -const computeBlockAriaLabel = function(block) { +const computeBlockAriaLabel = function (block) { // Guess the block's aria label based on its field labels. if (block.isShadow()) { // TODO: Shadows may have more than one field. @@ -277,7 +340,7 @@ const computeBlockAriaLabel = function(block) { return fieldLabels.join(' '); }; -const collectSiblingBlocksForBlock = function(block, surroundParent) { +const collectSiblingBlocksForBlock = function (block, surroundParent) { // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The // returned list needs to be relatively stable for consistency block indexes // read out to users via screen readers. @@ -288,7 +351,7 @@ const collectSiblingBlocksForBlock = function(block, surroundParent) { if (!firstSibling) throw new Error('No child in parent (somehow).'); const siblings = [firstSibling]; let nextSibling = firstSibling; - while (nextSibling = nextSibling.getNextBlock()) { + while ((nextSibling = nextSibling.getNextBlock())) { siblings.push(nextSibling); } return siblings; @@ -296,15 +359,17 @@ const collectSiblingBlocksForBlock = function(block, surroundParent) { // For top-level blocks, simply return those from the workspace. return block.workspace.getTopBlocks(false); } -} +}; -const computeLevelInWorkspaceForBlock = function(block) { +const computeLevelInWorkspaceForBlock = function (block) { const surroundParent = block.getSurroundParent(); - return surroundParent ? computeLevelInWorkspaceForBlock(surroundParent) + 1 : 0; -} + return surroundParent + ? computeLevelInWorkspaceForBlock(surroundParent) + 1 + : 0; +}; // TODO: Do this efficiently (probably centrally). -const recomputeAriaTreeItemDetailsInBlockRecursively = function(block) { +const recomputeAriaTreeItemDetailsInBlockRecursively = function (block) { const elem = block.getFocusableElement(); const connection = block.currentConnectionCandidate; let childPosition; @@ -328,7 +393,9 @@ const recomputeAriaTreeItemDetailsInBlockRecursively = function(block) { childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; } parentsChildCount = siblingBlocks.length + 1; - hierarchyDepth = surroundParent ? computeLevelInWorkspaceForBlock(surroundParent) + 1 : 1; + hierarchyDepth = surroundParent + ? computeLevelInWorkspaceForBlock(surroundParent) + 1 + : 1; } else { const surroundParent = block.getSurroundParent(); const siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); @@ -339,27 +406,34 @@ const recomputeAriaTreeItemDetailsInBlockRecursively = function(block) { aria.setState(elem, aria.State.POSINSET, childPosition); aria.setState(elem, aria.State.SETSIZE, parentsChildCount); aria.setState(elem, aria.State.LEVEL, hierarchyDepth); - block.getChildren(false).forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); + block + .getChildren(false) + .forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); }; -const announceDynamicAriaStateForBlock = function(block, isMoving, isCanceled, newLoc) { +const announceDynamicAriaStateForBlock = function ( + block, + isMoving, + isCanceled, + newLoc, +) { const connection = block.currentConnectionCandidate; if (isCanceled) { - aria.announceDynamicAriaState('Canceled movement') + aria.announceDynamicAriaState('Canceled movement'); return; } if (!isMoving) return; if (connection) { // TODO: Figure out general detachment. // TODO: Figure out how to deal with output connections. - let surroundParent = connection.sourceBlock_; + const surroundParent = connection.sourceBlock_; const announcementContext = []; announcementContext.push('Moving'); // TODO: Specialize for inserting? // NB: Old code here doesn't seem to handle parents correctly. if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { - announcementContext.push('to','input','of'); + announcementContext.push('to', 'input', 'of'); } else { - announcementContext.push('to','child','of'); + announcementContext.push('to', 'child', 'of'); } announcementContext.push(computeBlockAriaLabel(surroundParent)); @@ -369,11 +443,13 @@ const announceDynamicAriaStateForBlock = function(block, isMoving, isCanceled, n aria.announceDynamicAriaState(announcementContext.join(' ')); } else if (newLoc) { // The block is being freely dragged. - aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`); + aria.announceDynamicAriaState( + `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, + ); } -} +}; -Blockly.BlockSvg.prototype.doInit_ = function() { +Blockly.BlockSvg.prototype.doInit_ = function () { oldBlockSvgDoInit.call(this); const svgPath = this.getFocusableElement(); aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); @@ -383,40 +459,44 @@ Blockly.BlockSvg.prototype.doInit_ = function() { this.currentConnectionCandidate = null; }; -Blockly.BlockSvg.prototype.setParent = function(newParent) { +Blockly.BlockSvg.prototype.setParent = function (newParent) { oldBlockSvgSetParent.call(this, newParent); - this.workspace.getTopBlocks(false).forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); + this.workspace + .getTopBlocks(false) + .forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); }; -Blockly.BlockSvg.prototype.startDrag = function(e) { +Blockly.BlockSvg.prototype.startDrag = function (e) { oldBlockSvgStartDrag.call(this, e); - this.currentConnectionCandidate = this.dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = + this.dragStrategy.connectionCandidate?.neighbour ?? null; announceDynamicAriaStateForBlock(this, true, false); }; -Blockly.BlockSvg.prototype.drag = function(newLoc, e) { +Blockly.BlockSvg.prototype.drag = function (newLoc, e) { oldBlockSvgDrag.call(this, newLoc, e); - this.currentConnectionCandidate = this.dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = + this.dragStrategy.connectionCandidate?.neighbour ?? null; announceDynamicAriaStateForBlock(this, true, false, newLoc); }; -Blockly.BlockSvg.prototype.endDrag = function(e) { +Blockly.BlockSvg.prototype.endDrag = function (e) { oldBlockSvgEndDrag.call(this, e); this.currentConnectionCandidate = null; announceDynamicAriaStateForBlock(this, false, false); }; -Blockly.BlockSvg.prototype.revertDrag = function() { +Blockly.BlockSvg.prototype.revertDrag = function () { oldBlockSvgRevertDrag.call(this); announceDynamicAriaStateForBlock(this, false, true); }; -Blockly.BlockSvg.prototype.onNodeFocus = function() { +Blockly.BlockSvg.prototype.onNodeFocus = function () { oldBlockSvgOnNodeFocus.call(this); aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); -} +}; -Blockly.BlockSvg.prototype.onNodeBlur = function() { +Blockly.BlockSvg.prototype.onNodeBlur = function () { aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); oldBlockSvgOnNodeBlur.call(this); }; diff --git a/test/index.html b/test/index.html index a5492924..3411f4a6 100644 --- a/test/index.html +++ b/test/index.html @@ -146,11 +146,29 @@
Instructions for screen reader support:
    -
  • Enable a screen reader while on the page, and tab navigate/use arrow keys as normal.
  • -
  • It's recommended to enable text output (sometimes a developer feature) so that you can see what the screen reader will read out (which allows it to be muted, or even just helps to follow along).
  • -
  • It's recommended to use a keyboard shortcut for quickly enabling/disabling the screen reader to avoid needing to navigate back through menus to disable it.
  • +
  • + Enable a screen reader while on the page, and tab navigate/use + arrow keys as normal. +
  • +
  • + It's recommended to enable text output (sometimes a developer + feature) so that you can see what the screen reader will read + out (which allows it to be muted, or even just helps to follow + along). +
  • +
  • + It's recommended to use a keyboard shortcut for quickly + enabling/disabling the screen reader to avoid needing to + navigate back through menus to disable it. +
- For the primary discussion around screen reader support, please read and comment on: discussion #673. + For the primary discussion around screen reader support, please read + and comment on: discussion + #673. From 74549592e21e31a94e2e5f6e3dbdcf88cee52497 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 31 Jul 2025 00:00:22 +0000 Subject: [PATCH 05/17] fix: A few tests. This also removes some unnecessary code and one TODO. --- src/aria_monkey_patches.js | 8 +++++++- src/index.ts | 22 +++++++++++----------- src/move_icon.ts | 10 ---------- src/move_indicator.ts | 10 ---------- src/navigation_controller.ts | 1 - 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/aria_monkey_patches.js b/src/aria_monkey_patches.js index 39e2ee32..3d419e5f 100644 --- a/src/aria_monkey_patches.js +++ b/src/aria_monkey_patches.js @@ -27,14 +27,20 @@ document.createElementNS = function (namepspaceURI, qualifiedName) { }; const oldElementSetAttribute = Element.prototype.setAttribute; +// TODO: Replace these cases with property augmentation here so that all aria +// behavior is defined within this file. +const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected']; Element.prototype.setAttribute = function (name, value) { // This is a hacky way to disable all aria changes in core Blockly since it's // easier to just undefine everything globally and then conditionally reenable // things with the correct definitions. + // TODO: Add an exemption for role here once all roles are properly defined + // within this file (see failing tests when role changes are ignored here). if ( aria.isCurrentlyMutatingAriaProperty() || - (name !== 'role' && !name.startsWith('aria-')) + ariaAttributeAllowlist.includes(name) || + !name.startsWith('aria-') ) { oldElementSetAttribute.call(this, name, value); } diff --git a/src/index.ts b/src/index.ts index 094a2bbc..3199d5df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,16 +66,16 @@ export class KeyboardNavigation { workspace.addChangeListener(enableBlocksOnDrag); // Move the flyout for logical tab order. - // const flyout = workspace.getFlyout(); - // if (flyout != null && flyout instanceof Blockly.Flyout) { - // // This relies on internals. - // // eslint-disable-next-line @typescript-eslint/no-explicit-any - // const flyoutElement = ((flyout as any).svgGroup_ as SVGElement) ?? null; - // flyoutElement?.parentElement?.insertBefore( - // flyoutElement, - // workspace.getParentSvg(), - // ); - // } + const flyout = workspace.getFlyout(); + if (flyout != null && flyout instanceof Blockly.Flyout) { + // This relies on internals. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flyoutElement = ((flyout as any).svgGroup_ as SVGElement) ?? null; + flyoutElement?.parentElement?.insertBefore( + flyoutElement, + workspace.getParentSvg(), + ); + } this.oldWorkspaceResize = workspace.resize; workspace.resize = () => { @@ -296,7 +296,7 @@ export class KeyboardNavigation { stroke: var(--blockly-active-node-color); stroke-width: var(--blockly-selection-width); } - + /* The workspace itself is the active node. */ .blocklyKeyboardNavigation .blocklyBubble.blocklyActiveFocus diff --git a/src/move_icon.ts b/src/move_icon.ts index a3956128..3acd2ddf 100644 --- a/src/move_icon.ts +++ b/src/move_icon.ts @@ -139,14 +139,4 @@ export class MoveIcon implements Blockly.IIcon, Blockly.IHasBubble { canBeFocused(): boolean { return false; } - - /** See IFocusableNode.getAriaRole. */ - getAriaRole(): Blockly.utils.aria.Role | null { - throw new Error('This node is not focusable.'); - } - - /** See IFocusableNode.getAriaLabel. */ - getAriaLabel(): string { - throw new Error('This node is not focusable.'); - } } diff --git a/src/move_indicator.ts b/src/move_indicator.ts index 090e0a21..e6f8e92b 100644 --- a/src/move_indicator.ts +++ b/src/move_indicator.ts @@ -172,14 +172,4 @@ export class MoveIndicatorBubble canBeFocused(): boolean { return false; } - - /** See IFocusableNode.getAriaRole. */ - getAriaRole(): Blockly.utils.aria.Role | null { - throw new Error('This node is not focusable.'); - } - - /** See IFocusableNode.getAriaLabel. */ - getAriaLabel(): string { - throw new Error('This node is not focusable.'); - } } diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 7843c2dc..469dbf1a 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -36,7 +36,6 @@ import {Mover} from './actions/mover'; import {DuplicateAction} from './actions/duplicate'; import {StackNavigationAction} from './actions/stack_navigation'; -// TODO: Implement unregistration. import './aria_monkey_patches'; const KeyCodes = BlocklyUtils.KeyCodes; From 630137a82193c2eedb28651d0be2dbcd2ddd8fef Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 31 Jul 2025 18:28:49 +0000 Subject: [PATCH 06/17] chore: Remove movingBlock in mover. This property isn't needed right now. --- src/actions/mover.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/actions/mover.ts b/src/actions/mover.ts index cf986ab5..689bdba2 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -82,10 +82,6 @@ export class Mover { private moveIndicator?: MoveIndicatorBubble; - private movingBlock: - | (IDraggable & IFocusableNode & IBoundedElement & ISelectable) - | null = null; - constructor(protected navigation: Navigation) {} /** @@ -158,7 +154,6 @@ export class Mover { workspace.setKeyboardMoveInProgress(true); const info = new MoveInfo(workspace, draggable, dragger, blurListener); this.moves.set(workspace, info); - this.movingBlock = draggable; // Begin drag. dragger.onDragStart(info.fakePointerEvent('pointerdown')); info.updateTotalDelta(); @@ -222,7 +217,6 @@ export class Mover { */ finishMove(workspace: WorkspaceSvg) { const info = this.preDragEndCleanup(workspace); - this.movingBlock = null; info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), @@ -256,7 +250,6 @@ export class Mover { // Prevent the strategy connecting the block so we just delete one block. // @ts-expect-error Access to private property connectionCandidate. dragStrategy.connectionCandidate = null; - this.movingBlock = null; info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), From fad610f0cb1f446aa32f20fa2ca69e7c60787e3a Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 31 Jul 2025 23:10:55 +0000 Subject: [PATCH 07/17] chore: Split up monkey patches & type them. --- src/aria_monkey_patches.js | 512 ------------------ src/navigation_controller.ts | 9 +- src/{ => screenreader}/aria.ts | 0 src/screenreader/aria_monkey_patcher.js | 67 +++ src/screenreader/block_svg_utilities.ts | 116 ++++ src/screenreader/function_stubber_registry.ts | 96 ++++ .../stuboverrides/override_block_svg.ts | 50 ++ .../override_collapsible_toolbox_category.ts | 22 + .../stuboverrides/override_comment_icon.ts | 12 + .../stuboverrides/override_field.ts | 10 + .../stuboverrides/override_field_checkbox.ts | 13 + .../stuboverrides/override_field_dropdown.ts | 13 + .../stuboverrides/override_field_image.ts | 13 + .../stuboverrides/override_field_input.ts | 25 + .../stuboverrides/override_field_label.ts | 11 + .../stuboverrides/override_flyout_button.ts | 9 + .../stuboverrides/override_icon.ts | 9 + .../stuboverrides/override_mutator_icon.ts | 12 + .../override_rendered_connection.ts | 13 + .../override_rendered_workspace_comment.ts | 9 + .../stuboverrides/override_toolbox.ts | 7 + .../override_toolbox_category.ts | 10 + .../override_toolbox_separator.ts | 9 + .../stuboverrides/override_warning_icon.ts | 12 + .../stuboverrides/override_workspace_svg.ts | 20 + src/screenreader/toolbox_utilities.ts | 22 + test/index.ts | 2 +- 27 files changed, 589 insertions(+), 514 deletions(-) delete mode 100644 src/aria_monkey_patches.js rename src/{ => screenreader}/aria.ts (100%) create mode 100644 src/screenreader/aria_monkey_patcher.js create mode 100644 src/screenreader/block_svg_utilities.ts create mode 100644 src/screenreader/function_stubber_registry.ts create mode 100644 src/screenreader/stuboverrides/override_block_svg.ts create mode 100644 src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts create mode 100644 src/screenreader/stuboverrides/override_comment_icon.ts create mode 100644 src/screenreader/stuboverrides/override_field.ts create mode 100644 src/screenreader/stuboverrides/override_field_checkbox.ts create mode 100644 src/screenreader/stuboverrides/override_field_dropdown.ts create mode 100644 src/screenreader/stuboverrides/override_field_image.ts create mode 100644 src/screenreader/stuboverrides/override_field_input.ts create mode 100644 src/screenreader/stuboverrides/override_field_label.ts create mode 100644 src/screenreader/stuboverrides/override_flyout_button.ts create mode 100644 src/screenreader/stuboverrides/override_icon.ts create mode 100644 src/screenreader/stuboverrides/override_mutator_icon.ts create mode 100644 src/screenreader/stuboverrides/override_rendered_connection.ts create mode 100644 src/screenreader/stuboverrides/override_rendered_workspace_comment.ts create mode 100644 src/screenreader/stuboverrides/override_toolbox.ts create mode 100644 src/screenreader/stuboverrides/override_toolbox_category.ts create mode 100644 src/screenreader/stuboverrides/override_toolbox_separator.ts create mode 100644 src/screenreader/stuboverrides/override_warning_icon.ts create mode 100644 src/screenreader/stuboverrides/override_workspace_svg.ts create mode 100644 src/screenreader/toolbox_utilities.ts diff --git a/src/aria_monkey_patches.js b/src/aria_monkey_patches.js deleted file mode 100644 index 3d419e5f..00000000 --- a/src/aria_monkey_patches.js +++ /dev/null @@ -1,512 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Overrides a bunch of methods throughout core Blockly in order - * to augment Blockly components with ARIA support. - */ - -// TODO: Consider splitting this up into multiple files (might make things a bit easier). - -import * as Blockly from 'blockly/core'; -import * as aria from './aria'; - -const oldCreateElementNS = document.createElementNS; - -document.createElementNS = function (namepspaceURI, qualifiedName) { - const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName); - // Top-level SVG elements and groups are presentation by default. They will be - // specified more specifically elsewhere if they need to be readable. - if (qualifiedName === 'svg' || qualifiedName === 'g') { - aria.setRole(element, aria.Role.PRESENTATION); - } - return element; -}; - -const oldElementSetAttribute = Element.prototype.setAttribute; -// TODO: Replace these cases with property augmentation here so that all aria -// behavior is defined within this file. -const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected']; - -Element.prototype.setAttribute = function (name, value) { - // This is a hacky way to disable all aria changes in core Blockly since it's - // easier to just undefine everything globally and then conditionally reenable - // things with the correct definitions. - // TODO: Add an exemption for role here once all roles are properly defined - // within this file (see failing tests when role changes are ignored here). - if ( - aria.isCurrentlyMutatingAriaProperty() || - ariaAttributeAllowlist.includes(name) || - !name.startsWith('aria-') - ) { - oldElementSetAttribute.call(this, name, value); - } -}; - -const oldIconInitView = Blockly.icons.Icon.prototype.initView; - -Blockly.icons.Icon.prototype.initView = function (pointerdownListener) { - oldIconInitView.call(this, pointerdownListener); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Icon'); -}; - -const oldCommentIconInitView = Blockly.icons.CommentIcon.prototype.initView; - -Blockly.icons.CommentIcon.prototype.initView = function (pointerdownListener) { - oldCommentIconInitView.call(this, pointerdownListener); - const element = this.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', - ); -}; - -const oldMutatorIconInitView = Blockly.icons.MutatorIcon.prototype.initView; - -Blockly.icons.MutatorIcon.prototype.initView = function (pointerdownListener) { - oldMutatorIconInitView.call(this, pointerdownListener); - const element = this.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', - ); -}; - -const oldWarningIconInitView = Blockly.icons.WarningIcon.prototype.initView; - -Blockly.icons.WarningIcon.prototype.initView = function (pointerdownListener) { - oldWarningIconInitView.call(this, pointerdownListener); - const element = this.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', - ); -}; - -const oldFieldCreateTextElement = Blockly.Field.prototype.createTextElement_; - -Blockly.Field.prototype.createTextElement_ = function () { - oldFieldCreateTextElement.call(this); - // The text itself is presentation since it's represented through the - // block's ARIA label. - aria.setState(this.getTextElement(), aria.State.HIDDEN, true); -}; - -// TODO: This can be consolidated to FieldInput, but that's not exported so it has to be overwritten on a per-field basis. -const oldFieldNumberInit = Blockly.FieldNumber.prototype.init; -const oldFieldTextInputInit = Blockly.FieldTextInput.prototype.init; - -Blockly.FieldNumber.prototype.init = function () { - oldFieldNumberInit.call(this); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Text ${this.name}` : 'Text', - ); -}; - -Blockly.FieldTextInput.prototype.init = function () { - oldFieldTextInputInit.call(this); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Text ${this.name}` : 'Text', - ); -}; - -const oldFieldLabelInitView = Blockly.FieldLabel.prototype.initView; - -Blockly.FieldLabel.prototype.initView = function () { - oldFieldLabelInitView.call(this); - // There's no additional semantic meaning needed for a label; the aria-label - // should be sufficient for context. - aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText()); -}; - -const oldFieldImageInitView = Blockly.FieldImage.prototype.initView; - -Blockly.FieldImage.prototype.initView = function () { - oldFieldImageInitView.call(this); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.IMAGE); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Image ${this.name}` : 'Image', - ); -}; - -const oldFieldDropdownInitView = Blockly.FieldDropdown.prototype.initView; - -Blockly.FieldDropdown.prototype.initView = function () { - oldFieldDropdownInitView.call(this); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.LISTBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Item ${this.name}` : 'Item', - ); -}; - -const oldFieldCheckboxInitView = Blockly.FieldCheckbox.prototype.initView; - -Blockly.FieldCheckbox.prototype.initView = function () { - oldFieldCheckboxInitView.call(this); - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.CHECKBOX); - aria.setState( - element, - aria.State.LABEL, - this.name ? `Checkbox ${this.name}` : 'Checkbox', - ); -}; - -const oldFlyoutButtonUpdateTransform = - Blockly.FlyoutButton.prototype.updateTransform; - -Blockly.FlyoutButton.prototype.updateTransform = function () { - // This is a very hacky way to augment FlyoutButton's initialization since it - // happens in FlyoutButton's constructor (which can't be patched directly). - if (!this.isPatchInitialized) { - this.isPatchInitialized = true; - oldFlyoutButtonUpdateTransform.call(this); - - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.BUTTON); - aria.setState(element, aria.State.LABEL, 'Button'); - } -}; - -const oldRenderedWorkspaceCommentAddModelUpdateBindings = - Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings; - -Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings = - function () { - // This is a very hacky way to augment RenderedWorkspaceComments's - // initialization since it happens in RenderedWorkspaceComments's constructor - // (which can't be patched directly). - if (!this.isPatchInitialized) { - this.isPatchInitialized = true; - oldRenderedWorkspaceCommentAddModelUpdateBindings.call(this); - - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); - } - }; - -const oldRenderedConnectionFindHighlightSvg = - Blockly.RenderedConnection.prototype.findHighlightSvg; - -Blockly.RenderedConnection.prototype.findHighlightSvg = function () { - const element = oldRenderedConnectionFindHighlightSvg.call(this); - // This is a later initialization than most components but it's likely - // adequate since the creation of RenderedConnection's focusable element is - // part of the block rendering lifecycle (so the class itself isn't even aware - // when its element exists). - if (element) { - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Open connection'); - } - return element; -}; - -const oldWorkspaceSvgCreateDom = Blockly.WorkspaceSvg.prototype.createDom; - -Blockly.WorkspaceSvg.prototype.createDom = function ( - backgroundClass, - injectionDiv, -) { - const element = oldWorkspaceSvgCreateDom.call( - this, - backgroundClass, - injectionDiv, - ); - aria.setRole(element, aria.Role.TREE); - let ariaLabel = null; - if (this.injectionDiv) { - ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; - } else if (this.isFlyout) { - ariaLabel = 'Flyout'; - } else if (this.isMutator) { - ariaLabel = 'Mutator'; - } else { - throw new Error('Cannot determine ARIA label for workspace.'); - } - aria.setState(element, aria.State.LABEL, ariaLabel); - return element; -}; - -const oldToolboxCreateDom = Blockly.Toolbox.prototype.createDom_; - -Blockly.Toolbox.prototype.createDom_ = function (workspace) { - const element = oldToolboxCreateDom.call(this, workspace); - aria.setRole(element, aria.Role.TREE); - return element; -}; - -const recomputeAriaOwnersInToolbox = function (toolbox) { - const focusable = toolbox.getFocusableElement(); - const selectableChildren = - toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildElems = selectableChildren.map((selectable) => - selectable.getFocusableElement(), - ); - const focusableChildIds = focusableChildElems.map((elem) => elem.id); - aria.setState( - focusable, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - // Ensure children have the correct position set. - // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. - focusableChildElems.forEach((elem, index) => - aria.setState(elem, aria.State.POSINSET, index + 1), - ); -}; - -// TODO: Reimplement selected for items and expanded for categories, and levels. -const oldToolboxCategoryInit = Blockly.ToolboxCategory.prototype.init; - -Blockly.ToolboxCategory.prototype.init = function () { - oldToolboxCategoryInit.call(this); - aria.setRole(this.getFocusableElement(), aria.Role.TREEITEM); - recomputeAriaOwnersInToolbox(this.parentToolbox_); -}; - -const oldCollapsibleToolboxCategoryInit = - Blockly.CollapsibleToolboxCategory.prototype.init; - -Blockly.CollapsibleToolboxCategory.prototype.init = function () { - oldCollapsibleToolboxCategoryInit.call(this); - - const element = this.getFocusableElement(); - aria.setRole(element, aria.Role.GROUP); - - // Ensure this group has properly set children. - const selectableChildren = - this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildIds = selectableChildren.map( - (selectable) => selectable.getFocusableElement().id, - ); - aria.setState( - element, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - recomputeAriaOwnersInToolbox(this.parentToolbox_); -}; - -const oldToolboxSeparatorInit = Blockly.ToolboxSeparator.prototype.init; - -Blockly.ToolboxSeparator.prototype.init = function () { - oldToolboxSeparatorInit.call(this); - aria.setRole(this.getFocusableElement(), aria.Role.SEPARATOR); - recomputeAriaOwnersInToolbox(this.parentToolbox_); -}; - -const oldBlockSvgDoInit = Blockly.BlockSvg.prototype.doInit_; -const oldBlockSvgSetParent = Blockly.BlockSvg.prototype.setParent; -const oldBlockSvgStartDrag = Blockly.BlockSvg.prototype.startDrag; -const oldBlockSvgDrag = Blockly.BlockSvg.prototype.drag; -const oldBlockSvgEndDrag = Blockly.BlockSvg.prototype.endDrag; -const oldBlockSvgRevertDrag = Blockly.BlockSvg.prototype.revertDrag; -const oldBlockSvgOnNodeFocus = Blockly.BlockSvg.prototype.onNodeFocus; -const oldBlockSvgOnNodeBlur = Blockly.BlockSvg.prototype.onNodeBlur; - -const computeBlockAriaLabel = function (block) { - // Guess the block's aria label based on its field labels. - if (block.isShadow()) { - // TODO: Shadows may have more than one field. - // Shadow blocks are best represented directly by their field since they - // effectively operate like a field does for keyboard navigation purposes. - const field = Array.from(block.getFields())[0]; - return aria.getState(field.getFocusableElement(), aria.State.LABEL); - } - - const fieldLabels = []; - for (const field of block.getFields()) { - if (field instanceof Blockly.FieldLabel) { - fieldLabels.push(field.getText()); - } - } - return fieldLabels.join(' '); -}; - -const collectSiblingBlocksForBlock = function (block, surroundParent) { - // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The - // returned list needs to be relatively stable for consistency block indexes - // read out to users via screen readers. - if (surroundParent) { - // Start from the first sibling and iterate in navigation order. - const firstSibling = surroundParent.getChildren(false)[0]; - // TODO: Fix this case. It happens when replacing a block input with a new block. - if (!firstSibling) throw new Error('No child in parent (somehow).'); - const siblings = [firstSibling]; - let nextSibling = firstSibling; - while ((nextSibling = nextSibling.getNextBlock())) { - siblings.push(nextSibling); - } - return siblings; - } else { - // For top-level blocks, simply return those from the workspace. - return block.workspace.getTopBlocks(false); - } -}; - -const computeLevelInWorkspaceForBlock = function (block) { - const surroundParent = block.getSurroundParent(); - return surroundParent - ? computeLevelInWorkspaceForBlock(surroundParent) + 1 - : 0; -}; - -// TODO: Do this efficiently (probably centrally). -const recomputeAriaTreeItemDetailsInBlockRecursively = function (block) { - const elem = block.getFocusableElement(); - const connection = block.currentConnectionCandidate; - let childPosition; - let parentsChildCount; - let hierarchyDepth; - if (connection) { - // If the block is being inserted into a new location, the position is hypothetical. - // TODO: Figure out how to deal with output connections. - let surroundParent; - let siblingBlocks; - if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { - surroundParent = connection.sourceBlock_; - siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); - // The block is being added as a child since it's input. - // TODO: Figure out how to compute the correct position. - childPosition = 1; - } else { - surroundParent = connection.sourceBlock_.getSurroundParent(); - siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); - // The block is being added after the connected block. - childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; - } - parentsChildCount = siblingBlocks.length + 1; - hierarchyDepth = surroundParent - ? computeLevelInWorkspaceForBlock(surroundParent) + 1 - : 1; - } else { - const surroundParent = block.getSurroundParent(); - const siblingBlocks = collectSiblingBlocksForBlock(block, surroundParent); - childPosition = siblingBlocks.indexOf(block) + 1; - parentsChildCount = siblingBlocks.length; - hierarchyDepth = computeLevelInWorkspaceForBlock(block) + 1; - } - aria.setState(elem, aria.State.POSINSET, childPosition); - aria.setState(elem, aria.State.SETSIZE, parentsChildCount); - aria.setState(elem, aria.State.LEVEL, hierarchyDepth); - block - .getChildren(false) - .forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); -}; - -const announceDynamicAriaStateForBlock = function ( - block, - isMoving, - isCanceled, - newLoc, -) { - const connection = block.currentConnectionCandidate; - if (isCanceled) { - aria.announceDynamicAriaState('Canceled movement'); - return; - } - if (!isMoving) return; - if (connection) { - // TODO: Figure out general detachment. - // TODO: Figure out how to deal with output connections. - const surroundParent = connection.sourceBlock_; - const announcementContext = []; - announcementContext.push('Moving'); // TODO: Specialize for inserting? - // NB: Old code here doesn't seem to handle parents correctly. - if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { - announcementContext.push('to', 'input', 'of'); - } else { - announcementContext.push('to', 'child', 'of'); - } - - announcementContext.push(computeBlockAriaLabel(surroundParent)); - - // 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. - aria.announceDynamicAriaState( - `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, - ); - } -}; - -Blockly.BlockSvg.prototype.doInit_ = function () { - oldBlockSvgDoInit.call(this); - const svgPath = this.getFocusableElement(); - aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); - aria.setRole(svgPath, aria.Role.TREEITEM); - aria.setState(svgPath, aria.State.LABEL, computeBlockAriaLabel(this)); - svgPath.tabIndex = -1; - this.currentConnectionCandidate = null; -}; - -Blockly.BlockSvg.prototype.setParent = function (newParent) { - oldBlockSvgSetParent.call(this, newParent); - this.workspace - .getTopBlocks(false) - .forEach((block) => recomputeAriaTreeItemDetailsInBlockRecursively(block)); -}; - -Blockly.BlockSvg.prototype.startDrag = function (e) { - oldBlockSvgStartDrag.call(this, e); - this.currentConnectionCandidate = - this.dragStrategy.connectionCandidate?.neighbour ?? null; - announceDynamicAriaStateForBlock(this, true, false); -}; - -Blockly.BlockSvg.prototype.drag = function (newLoc, e) { - oldBlockSvgDrag.call(this, newLoc, e); - this.currentConnectionCandidate = - this.dragStrategy.connectionCandidate?.neighbour ?? null; - announceDynamicAriaStateForBlock(this, true, false, newLoc); -}; - -Blockly.BlockSvg.prototype.endDrag = function (e) { - oldBlockSvgEndDrag.call(this, e); - this.currentConnectionCandidate = null; - announceDynamicAriaStateForBlock(this, false, false); -}; - -Blockly.BlockSvg.prototype.revertDrag = function () { - oldBlockSvgRevertDrag.call(this); - announceDynamicAriaStateForBlock(this, false, true); -}; - -Blockly.BlockSvg.prototype.onNodeFocus = function () { - oldBlockSvgOnNodeFocus.call(this); - aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); -}; - -Blockly.BlockSvg.prototype.onNodeBlur = function () { - aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); - oldBlockSvgOnNodeBlur.call(this); -}; - -// TODO: Figure out how to patch CommentEditor. It doesn't seem to have any methods really to override, so it may actually require patching at the dom utility layer, or higher up. -// TODO: Ditto for CommentBarButton and its children. -// TODO: Ditto for Bubble and its children. diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 469dbf1a..cb638ef3 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -36,10 +36,15 @@ import {Mover} from './actions/mover'; import {DuplicateAction} from './actions/duplicate'; import {StackNavigationAction} from './actions/stack_navigation'; -import './aria_monkey_patches'; +import './screenreader/aria_monkey_patcher'; +import {FunctionStubber} from './screenreader/function_stubber_registry'; const KeyCodes = BlocklyUtils.KeyCodes; +// Note that prototype stubs must happen early in the page lifecycle in order to +// take effect before Blockly loading. +FunctionStubber.getInstance().stubPrototypes(); + /** * Class for registering shortcuts for keyboard navigation. */ @@ -292,5 +297,7 @@ export class NavigationController { } this.removeShortcutHandlers(); this.navigation.dispose(); + + FunctionStubber.getInstance().unstubPrototypes(); } } diff --git a/src/aria.ts b/src/screenreader/aria.ts similarity index 100% rename from src/aria.ts rename to src/screenreader/aria.ts diff --git a/src/screenreader/aria_monkey_patcher.js b/src/screenreader/aria_monkey_patcher.js new file mode 100644 index 00000000..a72a6995 --- /dev/null +++ b/src/screenreader/aria_monkey_patcher.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Overrides a bunch of methods throughout core Blockly in order + * to augment Blockly components with ARIA support. + */ + +import * as aria from './aria'; +import './stuboverrides/override_block_svg' +import './stuboverrides/override_collapsible_toolbox_category' +import './stuboverrides/override_comment_icon' +import './stuboverrides/override_field_checkbox' +import './stuboverrides/override_field_dropdown' +import './stuboverrides/override_field_image' +import './stuboverrides/override_field_input' +import './stuboverrides/override_field_label' +import './stuboverrides/override_field' +import './stuboverrides/override_flyout_button' +import './stuboverrides/override_icon' +import './stuboverrides/override_mutator_icon' +import './stuboverrides/override_rendered_connection' +import './stuboverrides/override_rendered_workspace_comment' +import './stuboverrides/override_toolbox_category' +import './stuboverrides/override_toolbox_separator' +import './stuboverrides/override_toolbox' +import './stuboverrides/override_warning_icon' +import './stuboverrides/override_workspace_svg' + +const oldCreateElementNS = document.createElementNS; + +document.createElementNS = function (namepspaceURI, qualifiedName) { + const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName); + // Top-level SVG elements and groups are presentation by default. They will be + // specified more specifically elsewhere if they need to be readable. + if (qualifiedName === 'svg' || qualifiedName === 'g') { + aria.setRole(element, aria.Role.PRESENTATION); + } + return element; +}; + +const oldElementSetAttribute = Element.prototype.setAttribute; +// TODO: Replace these cases with property augmentation here so that all aria +// behavior is defined within this file. +const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected']; + +Element.prototype.setAttribute = function (name, value) { + // This is a hacky way to disable all aria changes in core Blockly since it's + // easier to just undefine everything globally and then conditionally reenable + // things with the correct definitions. + // TODO: Add an exemption for role here once all roles are properly defined + // within this file (see failing tests when role changes are ignored here). + if ( + aria.isCurrentlyMutatingAriaProperty() || + ariaAttributeAllowlist.includes(name) || + !name.startsWith('aria-') + ) { + oldElementSetAttribute.call(this, name, value); + } +}; + +// TODO: Figure out how to patch CommentEditor. It doesn't seem to have any methods really to override, so it may actually require patching at the dom utility layer, or higher up. +// TODO: Ditto for CommentBarButton and its children. +// TODO: Ditto for Bubble and its children. diff --git a/src/screenreader/block_svg_utilities.ts b/src/screenreader/block_svg_utilities.ts new file mode 100644 index 00000000..360fadb8 --- /dev/null +++ b/src/screenreader/block_svg_utilities.ts @@ -0,0 +1,116 @@ +import * as Blockly from 'blockly/core'; +import * as aria from './aria'; + +export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { + // Guess the block's aria label based on its field labels. + if (block.isShadow()) { + // TODO: Shadows may have more than one field. + // Shadow blocks are best represented directly by their field since they + // effectively operate like a field does for keyboard navigation purposes. + const field = Array.from(block.getFields())[0]; + return aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?'; + } + + const fieldLabels = []; + for (const field of block.getFields()) { + if (field instanceof Blockly.FieldLabel) { + fieldLabels.push(field.getText()); + } + } + return fieldLabels.join(' '); +}; + +function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.BlockSvg | null): Blockly.BlockSvg[] { + // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The + // returned list needs to be relatively stable for consistency block indexes + // read out to users via screen readers. + if (surroundParent) { + // Start from the first sibling and iterate in navigation order. + const firstSibling: Blockly.BlockSvg = surroundParent.getChildren(false)[0]; + const siblings: Blockly.BlockSvg[] = [firstSibling]; + let nextSibling: Blockly.BlockSvg | null = firstSibling; + while (nextSibling = nextSibling.getNextBlock()) { + siblings.push(nextSibling); + } + return siblings; + } else { + // For top-level blocks, simply return those from the workspace. + return block.workspace.getTopBlocks(false); + } +} + +function computeLevelInWorkspace(block: Blockly.BlockSvg): number { + const surroundParent = block.getSurroundParent(); + return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0; +}; + +// TODO: Do this efficiently (probably centrally). +export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) { + const elem = block.getFocusableElement(); + const connection = (block as any).currentConnectionCandidate; + let childPosition: number; + let parentsChildCount: number; + let hierarchyDepth: number; + if (connection) { + // If the block is being inserted into a new location, the position is hypothetical. + // TODO: Figure out how to deal with output connections. + let surroundParent: Blockly.BlockSvg | null; + let siblingBlocks: Blockly.BlockSvg[]; + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { + surroundParent = connection.sourceBlock_; + siblingBlocks = collectSiblingBlocks(block, surroundParent); + // The block is being added as a child since it's input. + // TODO: Figure out how to compute the correct position. + childPosition = 1; + } else { + surroundParent = connection.sourceBlock_.getSurroundParent(); + siblingBlocks = collectSiblingBlocks(block, surroundParent); + // The block is being added after the connected block. + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; + } + parentsChildCount = siblingBlocks.length + 1; + hierarchyDepth = surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 1; + } else { + const surroundParent = block.getSurroundParent(); + const siblingBlocks = collectSiblingBlocks(block, surroundParent); + childPosition = siblingBlocks.indexOf(block) + 1; + parentsChildCount = siblingBlocks.length; + hierarchyDepth = computeLevelInWorkspace(block) + 1; + } + aria.setState(elem, aria.State.POSINSET, childPosition); + aria.setState(elem, aria.State.SETSIZE, parentsChildCount); + aria.setState(elem, aria.State.LEVEL, hierarchyDepth); + block.getChildren(false).forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); +} + +export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMoving: boolean, isCanceled: boolean, newLoc?: Blockly.utils.Coordinate) { + const connection = (block as any).currentConnectionCandidate; + if (isCanceled) { + aria.announceDynamicAriaState('Canceled movement'); + return; + } + if (!isMoving) return; + if (connection) { + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + let surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { + announcementContext.push('to', 'input'); + } else { + announcementContext.push('to', 'child'); + } + if (surroundParent) { + announcementContext.push('of', computeBlockAriaLabel(surroundParent)); + } + + // 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. + aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`); + } +} diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts new file mode 100644 index 00000000..79dbfa6b --- /dev/null +++ b/src/screenreader/function_stubber_registry.ts @@ -0,0 +1,96 @@ +export type StubCallback = (instance: T, ...args: any) => void; + +class Registration { + private oldMethod: ((...args: any) => any) | null = null; + + constructor( + readonly callback: StubCallback, + readonly methodNameToOverride: string, + readonly classPrototype: T, + readonly ensureOneCall: boolean + ) {} + + stubPrototype(): void { + // TODO: Figure out how to make this work with minification. + if (this.oldMethod) { + throw new Error(`Function is already stubbed: ${this.methodNameToOverride}.`); + } + const genericPrototype = this.classPrototype as any; + const oldMethod = + genericPrototype[this.methodNameToOverride] as (...args: any) => any; + this.oldMethod = oldMethod; + const registration = this; + genericPrototype[this.methodNameToOverride] = function (...args: any): any { + let stubsCalled = + this._internalStubsCalled as {[key: string]: boolean} | undefined; + if (!stubsCalled) { + stubsCalled = {}; + this._internalStubsCalled = stubsCalled; + } + + const result = oldMethod.call(this, ...args); + if (!registration.ensureOneCall || !stubsCalled[registration.methodNameToOverride]) { + registration.callback(this as unknown as T, ...args); + stubsCalled[registration.methodNameToOverride] = true; + } + return result; + }; + } + + unstubPrototype(): void { + if (this.oldMethod) { + throw new Error(`Function is not currently stubbed: ${this.methodNameToOverride}.`); + } + const genericPrototype = this.classPrototype as any; + genericPrototype[this.methodNameToOverride] = this.oldMethod; + this.oldMethod = null; + } +} + +export class FunctionStubber { + private registrations: Registration[] = []; + private isFinalized: boolean = false; + + public registerInitializationStub( + callback: StubCallback, + methodNameToOverride: string, + classPrototype: T + ) { + if (this.isFinalized) { + throw new Error('Cannot register a stub after initialization has been completed.'); + } + const registration = new Registration(callback, methodNameToOverride, classPrototype, true); + this.registrations.push(registration); + } + + public registerMethodStub( + callback: StubCallback, + methodNameToOverride: string, + classPrototype: T + ) { + if (this.isFinalized) { + throw new Error('Cannot register a stub after initialization has been completed.'); + } + const registration = new Registration(callback, methodNameToOverride, classPrototype, false); + this.registrations.push(registration); + } + + public stubPrototypes() { + this.isFinalized = true; + this.registrations.forEach((registration) => registration.stubPrototype()); + } + + public unstubPrototypes() { + this.registrations.forEach((registration) => registration.unstubPrototype()); + this.isFinalized = false; + } + + private static instance: FunctionStubber | null = null; + + static getInstance(): FunctionStubber { + if (!FunctionStubber.instance) { + FunctionStubber.instance = new FunctionStubber(); + } + return FunctionStubber.instance; + } +} diff --git a/src/screenreader/stuboverrides/override_block_svg.ts b/src/screenreader/stuboverrides/override_block_svg.ts new file mode 100644 index 00000000..4a12b1dc --- /dev/null +++ b/src/screenreader/stuboverrides/override_block_svg.ts @@ -0,0 +1,50 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; +import * as blockSvgUtils from '../block_svg_utilities'; + +FunctionStubber.getInstance().registerInitializationStub((block) => { + const svgPath = block.getFocusableElement(); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, aria.Role.TREEITEM); + aria.setState(svgPath, aria.State.LABEL, blockSvgUtils.computeBlockAriaLabel(block)); + svgPath.tabIndex = -1; + (block as any).currentConnectionCandidate = null; +}, 'doInit_', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + block.workspace + .getTopBlocks(false) + .forEach((block) => blockSvgUtils.recomputeAriaTreeItemDetailsRecursively(block)); +}, 'setParent', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + (block as any).currentConnectionCandidate = + // @ts-expect-error Access to private property dragStrategy. + block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); +}, 'startDrag', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block, newLoc: Blockly.utils.Coordinate) => { + (block as any).currentConnectionCandidate = + // @ts-expect-error Access to private property dragStrategy. + block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); +}, 'drag', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + (block as any).currentConnectionCandidate = null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); +}, 'endDrag', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true); +}, 'revertDrag', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + aria.setState(block.getFocusableElement(), aria.State.SELECTED, true); +}, 'onNodeFocus', Blockly.BlockSvg.prototype); + +FunctionStubber.getInstance().registerMethodStub((block) => { + aria.setState(block.getFocusableElement(), aria.State.SELECTED, false); +}, 'onNodeBlur', Blockly.BlockSvg.prototype); diff --git a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts new file mode 100644 index 00000000..f4c3d26b --- /dev/null +++ b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts @@ -0,0 +1,22 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; +import * as toolboxUtils from '../toolbox_utilities'; + +FunctionStubber.getInstance().registerInitializationStub((category) => { + const element = category.getFocusableElement(); + aria.setRole(element, aria.Role.GROUP); + + // Ensure this group has properly set children. + const selectableChildren = + category.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map( + (selectable) => selectable.getFocusableElement().id, + ); + aria.setState( + element, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + toolboxUtils.recomputeAriaOwnersInToolbox(category.getFocusableTree() as Blockly.Toolbox); +}, 'init', Blockly.CollapsibleToolboxCategory.prototype); diff --git a/src/screenreader/stuboverrides/override_comment_icon.ts b/src/screenreader/stuboverrides/override_comment_icon.ts new file mode 100644 index 00000000..7f99cc97 --- /dev/null +++ b/src/screenreader/stuboverrides/override_comment_icon.ts @@ -0,0 +1,12 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); +}, 'initView', Blockly.icons.CommentIcon.prototype); diff --git a/src/screenreader/stuboverrides/override_field.ts b/src/screenreader/stuboverrides/override_field.ts new file mode 100644 index 00000000..f4f82127 --- /dev/null +++ b/src/screenreader/stuboverrides/override_field.ts @@ -0,0 +1,10 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((field) => { + // The text itself is presentation since it's represented through the + // block's ARIA label. + // @ts-expect-error Access to private property getTextElement. + aria.setState(field.getTextElement(), aria.State.HIDDEN, true); +}, 'createTextElement_', Blockly.Field.prototype); diff --git a/src/screenreader/stuboverrides/override_field_checkbox.ts b/src/screenreader/stuboverrides/override_field_checkbox.ts new file mode 100644 index 00000000..bc614b0b --- /dev/null +++ b/src/screenreader/stuboverrides/override_field_checkbox.ts @@ -0,0 +1,13 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((fieldCheckbox) => { + const element = fieldCheckbox.getFocusableElement(); + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + fieldCheckbox.name ? `Checkbox ${fieldCheckbox.name}` : 'Checkbox', + ); +}, 'initView', Blockly.FieldCheckbox.prototype); diff --git a/src/screenreader/stuboverrides/override_field_dropdown.ts b/src/screenreader/stuboverrides/override_field_dropdown.ts new file mode 100644 index 00000000..f2c2c92b --- /dev/null +++ b/src/screenreader/stuboverrides/override_field_dropdown.ts @@ -0,0 +1,13 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((fieldDropdown) => { + const element = fieldDropdown.getFocusableElement(); + aria.setRole(element, aria.Role.LISTBOX); + aria.setState( + element, + aria.State.LABEL, + fieldDropdown.name ? `Item ${fieldDropdown.name}` : 'Item', + ); +}, 'initView', Blockly.FieldDropdown.prototype); diff --git a/src/screenreader/stuboverrides/override_field_image.ts b/src/screenreader/stuboverrides/override_field_image.ts new file mode 100644 index 00000000..0711be29 --- /dev/null +++ b/src/screenreader/stuboverrides/override_field_image.ts @@ -0,0 +1,13 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((fieldImage) => { + const element = fieldImage.getFocusableElement(); + aria.setRole(element, aria.Role.IMAGE); + aria.setState( + element, + aria.State.LABEL, + fieldImage.name ? `Image ${fieldImage.name}` : 'Image', + ); +}, 'initView', Blockly.FieldImage.prototype); diff --git a/src/screenreader/stuboverrides/override_field_input.ts b/src/screenreader/stuboverrides/override_field_input.ts new file mode 100644 index 00000000..3206237b --- /dev/null +++ b/src/screenreader/stuboverrides/override_field_input.ts @@ -0,0 +1,25 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +// Note: These can be consolidated to FieldInput, but that's not exported so it +// has to be overwritten on a per-field basis. +FunctionStubber.getInstance().registerInitializationStub((fieldNumber) => { + initializeFieldInput(fieldNumber); +}, 'init', Blockly.FieldNumber.prototype); + +FunctionStubber.getInstance().registerInitializationStub((fieldTextInput) => { + initializeFieldInput(fieldTextInput); +}, 'init', Blockly.FieldTextInput.prototype); + +function initializeFieldInput( + fieldInput: Blockly.FieldNumber | Blockly.FieldTextInput +): void { + const element = fieldInput.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState( + element, + aria.State.LABEL, + fieldInput.name ? `Text ${fieldInput.name}` : 'Text', + ); +} diff --git a/src/screenreader/stuboverrides/override_field_label.ts b/src/screenreader/stuboverrides/override_field_label.ts new file mode 100644 index 00000000..a5f5e202 --- /dev/null +++ b/src/screenreader/stuboverrides/override_field_label.ts @@ -0,0 +1,11 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((fieldLabel) => { + // There's no additional semantic meaning needed for a label; the aria-label + // should be sufficient for context. + aria.setState( + fieldLabel.getFocusableElement(), aria.State.LABEL, fieldLabel.getText() + ); +}, 'initView', Blockly.FieldLabel.prototype); diff --git a/src/screenreader/stuboverrides/override_flyout_button.ts b/src/screenreader/stuboverrides/override_flyout_button.ts new file mode 100644 index 00000000..dc0e7e03 --- /dev/null +++ b/src/screenreader/stuboverrides/override_flyout_button.ts @@ -0,0 +1,9 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((flyoutButton) => { + const element = flyoutButton.getFocusableElement(); + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, 'Button'); +}, 'updateTransform', Blockly.FlyoutButton.prototype); diff --git a/src/screenreader/stuboverrides/override_icon.ts b/src/screenreader/stuboverrides/override_icon.ts new file mode 100644 index 00000000..aa4e35a6 --- /dev/null +++ b/src/screenreader/stuboverrides/override_icon.ts @@ -0,0 +1,9 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((icon) => { + const element = icon.getFocusableElement(); + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Icon'); +}, 'initView', Blockly.icons.Icon.prototype); diff --git a/src/screenreader/stuboverrides/override_mutator_icon.ts b/src/screenreader/stuboverrides/override_mutator_icon.ts new file mode 100644 index 00000000..e0ca8878 --- /dev/null +++ b/src/screenreader/stuboverrides/override_mutator_icon.ts @@ -0,0 +1,12 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); +}, 'initView', Blockly.icons.MutatorIcon.prototype); diff --git a/src/screenreader/stuboverrides/override_rendered_connection.ts b/src/screenreader/stuboverrides/override_rendered_connection.ts new file mode 100644 index 00000000..15779a07 --- /dev/null +++ b/src/screenreader/stuboverrides/override_rendered_connection.ts @@ -0,0 +1,13 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((connection) => { + // This is a later initialization than most components but it's likely + // adequate since the creation of RenderedConnection's focusable element is + // part of the block rendering lifecycle (so the class itself isn't even aware + // when its element exists). + const element = connection.getFocusableElement(); + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Open connection'); +}, 'highlight', Blockly.RenderedConnection.prototype); diff --git a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts new file mode 100644 index 00000000..0a87af6b --- /dev/null +++ b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts @@ -0,0 +1,9 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((comment) => { + const element = comment.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); +}, 'addModelUpdateBindings', Blockly.comments.RenderedWorkspaceComment.prototype); diff --git a/src/screenreader/stuboverrides/override_toolbox.ts b/src/screenreader/stuboverrides/override_toolbox.ts new file mode 100644 index 00000000..d746682d --- /dev/null +++ b/src/screenreader/stuboverrides/override_toolbox.ts @@ -0,0 +1,7 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((toolbox) => { + aria.setRole(toolbox.getFocusableElement(), aria.Role.TREE); +}, 'init', Blockly.Toolbox.prototype); diff --git a/src/screenreader/stuboverrides/override_toolbox_category.ts b/src/screenreader/stuboverrides/override_toolbox_category.ts new file mode 100644 index 00000000..a24b88a9 --- /dev/null +++ b/src/screenreader/stuboverrides/override_toolbox_category.ts @@ -0,0 +1,10 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; +import * as toolboxUtils from '../toolbox_utilities'; + +// TODO: Reimplement selected for items and expanded for categories, and levels. +FunctionStubber.getInstance().registerInitializationStub((category) => { + aria.setRole(category.getFocusableElement(), aria.Role.TREEITEM); + toolboxUtils.recomputeAriaOwnersInToolbox(category.getFocusableTree() as Blockly.Toolbox); +}, 'init', Blockly.ToolboxCategory.prototype); diff --git a/src/screenreader/stuboverrides/override_toolbox_separator.ts b/src/screenreader/stuboverrides/override_toolbox_separator.ts new file mode 100644 index 00000000..e140af60 --- /dev/null +++ b/src/screenreader/stuboverrides/override_toolbox_separator.ts @@ -0,0 +1,9 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; +import * as toolboxUtils from '../toolbox_utilities'; + +FunctionStubber.getInstance().registerInitializationStub((separator) => { + aria.setRole(separator.getFocusableElement(), aria.Role.SEPARATOR); + toolboxUtils.recomputeAriaOwnersInToolbox(separator.getFocusableTree() as Blockly.Toolbox); +}, 'init', Blockly.ToolboxSeparator.prototype); diff --git a/src/screenreader/stuboverrides/override_warning_icon.ts b/src/screenreader/stuboverrides/override_warning_icon.ts new file mode 100644 index 00000000..588e4f7f --- /dev/null +++ b/src/screenreader/stuboverrides/override_warning_icon.ts @@ -0,0 +1,12 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); +}, 'initView', Blockly.icons.WarningIcon.prototype); diff --git a/src/screenreader/stuboverrides/override_workspace_svg.ts b/src/screenreader/stuboverrides/override_workspace_svg.ts new file mode 100644 index 00000000..af16f1d5 --- /dev/null +++ b/src/screenreader/stuboverrides/override_workspace_svg.ts @@ -0,0 +1,20 @@ +import {FunctionStubber} from '../function_stubber_registry'; +import * as Blockly from 'blockly/core'; +import * as aria from '../aria'; + +FunctionStubber.getInstance().registerInitializationStub((workspace) => { + const element = workspace.getFocusableElement(); + aria.setRole(element, aria.Role.TREE); + let ariaLabel = null; + // @ts-expect-error Access to private property injectionDiv. + if (workspace.injectionDiv) { + ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; + } else if (workspace.isFlyout) { + ariaLabel = 'Flyout'; + } else if (workspace.isMutator) { + ariaLabel = 'Mutator'; + } else { + throw new Error('Cannot determine ARIA label for workspace.'); + } + aria.setState(element, aria.State.LABEL, ariaLabel); +}, 'createDom', Blockly.WorkspaceSvg.prototype); diff --git a/src/screenreader/toolbox_utilities.ts b/src/screenreader/toolbox_utilities.ts new file mode 100644 index 00000000..53fc3a6d --- /dev/null +++ b/src/screenreader/toolbox_utilities.ts @@ -0,0 +1,22 @@ +import * as Blockly from 'blockly/core'; +import * as aria from './aria'; + +export function recomputeAriaOwnersInToolbox(toolbox: Blockly.Toolbox) { + const focusable = toolbox.getFocusableElement(); + const selectableChildren = + toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildElems = selectableChildren.map((selectable) => + selectable.getFocusableElement(), + ); + const focusableChildIds = focusableChildElems.map((elem) => elem.id); + aria.setState( + focusable, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + // Ensure children have the correct position set. + // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. + focusableChildElems.forEach((elem, index) => + aria.setState(elem, aria.State.POSINSET, index + 1), + ); +}; diff --git a/test/index.ts b/test/index.ts index 4d1b1039..5d60b447 100644 --- a/test/index.ts +++ b/test/index.ts @@ -24,7 +24,7 @@ import {javascriptGenerator} from 'blockly/javascript'; // @ts-expect-error No types in js file import {load} from './loadTestBlocks'; import {runCode, registerRunCodeShortcut} from './runCode'; -import * as aria from '../src/aria'; +import * as aria from '../src/screenreader/aria'; (window as unknown as {Blockly: typeof Blockly}).Blockly = Blockly; From 955fc5679bf4dc983666e7a69f6ae3fae21a4a5d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 31 Jul 2025 23:15:22 +0000 Subject: [PATCH 08/17] chore: Lint fixes. --- src/screenreader/aria_monkey_patcher.js | 38 +++--- src/screenreader/block_svg_utilities.ts | 40 ++++-- src/screenreader/function_stubber_registry.ts | 68 +++++++--- .../stuboverrides/override_block_svg.ts | 128 ++++++++++++------ .../override_collapsible_toolbox_category.ts | 39 +++--- .../stuboverrides/override_comment_icon.ts | 20 +-- .../stuboverrides/override_field.ts | 16 ++- .../stuboverrides/override_field_checkbox.ts | 22 +-- .../stuboverrides/override_field_dropdown.ts | 22 +-- .../stuboverrides/override_field_image.ts | 22 +-- .../stuboverrides/override_field_input.ts | 22 ++- .../stuboverrides/override_field_label.ts | 20 ++- .../stuboverrides/override_flyout_button.ts | 14 +- .../stuboverrides/override_icon.ts | 14 +- .../stuboverrides/override_mutator_icon.ts | 20 +-- .../override_rendered_connection.ts | 22 +-- .../override_rendered_workspace_comment.ts | 14 +- .../stuboverrides/override_toolbox.ts | 10 +- .../override_toolbox_category.ts | 14 +- .../override_toolbox_separator.ts | 14 +- .../stuboverrides/override_warning_icon.ts | 20 +-- .../stuboverrides/override_workspace_svg.ts | 36 ++--- src/screenreader/toolbox_utilities.ts | 2 +- 23 files changed, 402 insertions(+), 235 deletions(-) diff --git a/src/screenreader/aria_monkey_patcher.js b/src/screenreader/aria_monkey_patcher.js index a72a6995..06ff9d51 100644 --- a/src/screenreader/aria_monkey_patcher.js +++ b/src/screenreader/aria_monkey_patcher.js @@ -10,25 +10,25 @@ */ import * as aria from './aria'; -import './stuboverrides/override_block_svg' -import './stuboverrides/override_collapsible_toolbox_category' -import './stuboverrides/override_comment_icon' -import './stuboverrides/override_field_checkbox' -import './stuboverrides/override_field_dropdown' -import './stuboverrides/override_field_image' -import './stuboverrides/override_field_input' -import './stuboverrides/override_field_label' -import './stuboverrides/override_field' -import './stuboverrides/override_flyout_button' -import './stuboverrides/override_icon' -import './stuboverrides/override_mutator_icon' -import './stuboverrides/override_rendered_connection' -import './stuboverrides/override_rendered_workspace_comment' -import './stuboverrides/override_toolbox_category' -import './stuboverrides/override_toolbox_separator' -import './stuboverrides/override_toolbox' -import './stuboverrides/override_warning_icon' -import './stuboverrides/override_workspace_svg' +import './stuboverrides/override_block_svg'; +import './stuboverrides/override_collapsible_toolbox_category'; +import './stuboverrides/override_comment_icon'; +import './stuboverrides/override_field_checkbox'; +import './stuboverrides/override_field_dropdown'; +import './stuboverrides/override_field_image'; +import './stuboverrides/override_field_input'; +import './stuboverrides/override_field_label'; +import './stuboverrides/override_field'; +import './stuboverrides/override_flyout_button'; +import './stuboverrides/override_icon'; +import './stuboverrides/override_mutator_icon'; +import './stuboverrides/override_rendered_connection'; +import './stuboverrides/override_rendered_workspace_comment'; +import './stuboverrides/override_toolbox_category'; +import './stuboverrides/override_toolbox_separator'; +import './stuboverrides/override_toolbox'; +import './stuboverrides/override_warning_icon'; +import './stuboverrides/override_workspace_svg'; const oldCreateElementNS = document.createElementNS; diff --git a/src/screenreader/block_svg_utilities.ts b/src/screenreader/block_svg_utilities.ts index 360fadb8..6dcc86c5 100644 --- a/src/screenreader/block_svg_utilities.ts +++ b/src/screenreader/block_svg_utilities.ts @@ -8,7 +8,9 @@ export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { // Shadow blocks are best represented directly by their field since they // effectively operate like a field does for keyboard navigation purposes. const field = Array.from(block.getFields())[0]; - return aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?'; + return ( + aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?' + ); } const fieldLabels = []; @@ -18,9 +20,12 @@ export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { } } return fieldLabels.join(' '); -}; +} -function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.BlockSvg | null): Blockly.BlockSvg[] { +function collectSiblingBlocks( + block: Blockly.BlockSvg, + surroundParent: Blockly.BlockSvg | null, +): Blockly.BlockSvg[] { // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The // returned list needs to be relatively stable for consistency block indexes // read out to users via screen readers. @@ -29,7 +34,7 @@ function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.B const firstSibling: Blockly.BlockSvg = surroundParent.getChildren(false)[0]; const siblings: Blockly.BlockSvg[] = [firstSibling]; let nextSibling: Blockly.BlockSvg | null = firstSibling; - while (nextSibling = nextSibling.getNextBlock()) { + while ((nextSibling = nextSibling.getNextBlock())) { siblings.push(nextSibling); } return siblings; @@ -42,10 +47,12 @@ function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.B function computeLevelInWorkspace(block: Blockly.BlockSvg): number { const surroundParent = block.getSurroundParent(); return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0; -}; +} // TODO: Do this efficiently (probably centrally). -export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) { +export function recomputeAriaTreeItemDetailsRecursively( + block: Blockly.BlockSvg, +) { const elem = block.getFocusableElement(); const connection = (block as any).currentConnectionCandidate; let childPosition: number; @@ -69,7 +76,9 @@ export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; } parentsChildCount = siblingBlocks.length + 1; - hierarchyDepth = surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 1; + hierarchyDepth = surroundParent + ? computeLevelInWorkspace(surroundParent) + 1 + : 1; } else { const surroundParent = block.getSurroundParent(); const siblingBlocks = collectSiblingBlocks(block, surroundParent); @@ -80,10 +89,17 @@ export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) aria.setState(elem, aria.State.POSINSET, childPosition); aria.setState(elem, aria.State.SETSIZE, parentsChildCount); aria.setState(elem, aria.State.LEVEL, hierarchyDepth); - block.getChildren(false).forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); + block + .getChildren(false) + .forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); } -export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMoving: boolean, isCanceled: boolean, newLoc?: Blockly.utils.Coordinate) { +export function announceDynamicAriaStateForBlock( + block: Blockly.BlockSvg, + isMoving: boolean, + isCanceled: boolean, + newLoc?: Blockly.utils.Coordinate, +) { const connection = (block as any).currentConnectionCandidate; if (isCanceled) { aria.announceDynamicAriaState('Canceled movement'); @@ -93,7 +109,7 @@ export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMovi if (connection) { // TODO: Figure out general detachment. // TODO: Figure out how to deal with output connections. - let surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_; + const surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_; const announcementContext = []; announcementContext.push('Moving'); // TODO: Specialize for inserting? // NB: Old code here doesn't seem to handle parents correctly. @@ -111,6 +127,8 @@ export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMovi aria.announceDynamicAriaState(announcementContext.join(' ')); } else if (newLoc) { // The block is being freely dragged. - aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`); + aria.announceDynamicAriaState( + `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, + ); } } diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts index 79dbfa6b..f8e74833 100644 --- a/src/screenreader/function_stubber_registry.ts +++ b/src/screenreader/function_stubber_registry.ts @@ -7,29 +7,37 @@ class Registration { readonly callback: StubCallback, readonly methodNameToOverride: string, readonly classPrototype: T, - readonly ensureOneCall: boolean + readonly ensureOneCall: boolean, ) {} stubPrototype(): void { // TODO: Figure out how to make this work with minification. if (this.oldMethod) { - throw new Error(`Function is already stubbed: ${this.methodNameToOverride}.`); + throw new Error( + `Function is already stubbed: ${this.methodNameToOverride}.`, + ); } const genericPrototype = this.classPrototype as any; - const oldMethod = - genericPrototype[this.methodNameToOverride] as (...args: any) => any; + const oldMethod = genericPrototype[this.methodNameToOverride] as ( + ...args: any + ) => any; this.oldMethod = oldMethod; + // eslint-disable-next-line @typescript-eslint/no-this-alias const registration = this; genericPrototype[this.methodNameToOverride] = function (...args: any): any { - let stubsCalled = - this._internalStubsCalled as {[key: string]: boolean} | undefined; + let stubsCalled = this._internalStubsCalled as + | {[key: string]: boolean} + | undefined; if (!stubsCalled) { stubsCalled = {}; this._internalStubsCalled = stubsCalled; } const result = oldMethod.call(this, ...args); - if (!registration.ensureOneCall || !stubsCalled[registration.methodNameToOverride]) { + if ( + !registration.ensureOneCall || + !stubsCalled[registration.methodNameToOverride] + ) { registration.callback(this as unknown as T, ...args); stubsCalled[registration.methodNameToOverride] = true; } @@ -39,7 +47,9 @@ class Registration { unstubPrototype(): void { if (this.oldMethod) { - throw new Error(`Function is not currently stubbed: ${this.methodNameToOverride}.`); + throw new Error( + `Function is not currently stubbed: ${this.methodNameToOverride}.`, + ); } const genericPrototype = this.classPrototype as any; genericPrototype[this.methodNameToOverride] = this.oldMethod; @@ -48,40 +58,56 @@ class Registration { } export class FunctionStubber { - private registrations: Registration[] = []; - private isFinalized: boolean = false; + private registrations: Array> = []; + private isFinalized = false; - public registerInitializationStub( + registerInitializationStub( callback: StubCallback, methodNameToOverride: string, - classPrototype: T + classPrototype: T, ) { if (this.isFinalized) { - throw new Error('Cannot register a stub after initialization has been completed.'); + throw new Error( + 'Cannot register a stub after initialization has been completed.', + ); } - const registration = new Registration(callback, methodNameToOverride, classPrototype, true); + const registration = new Registration( + callback, + methodNameToOverride, + classPrototype, + true, + ); this.registrations.push(registration); } - public registerMethodStub( + registerMethodStub( callback: StubCallback, methodNameToOverride: string, - classPrototype: T + classPrototype: T, ) { if (this.isFinalized) { - throw new Error('Cannot register a stub after initialization has been completed.'); + throw new Error( + 'Cannot register a stub after initialization has been completed.', + ); } - const registration = new Registration(callback, methodNameToOverride, classPrototype, false); + const registration = new Registration( + callback, + methodNameToOverride, + classPrototype, + false, + ); this.registrations.push(registration); } - public stubPrototypes() { + stubPrototypes() { this.isFinalized = true; this.registrations.forEach((registration) => registration.stubPrototype()); } - public unstubPrototypes() { - this.registrations.forEach((registration) => registration.unstubPrototype()); + unstubPrototypes() { + this.registrations.forEach((registration) => + registration.unstubPrototype(), + ); this.isFinalized = false; } diff --git a/src/screenreader/stuboverrides/override_block_svg.ts b/src/screenreader/stuboverrides/override_block_svg.ts index 4a12b1dc..2ee07392 100644 --- a/src/screenreader/stuboverrides/override_block_svg.ts +++ b/src/screenreader/stuboverrides/override_block_svg.ts @@ -3,48 +3,86 @@ import * as Blockly from 'blockly/core'; import * as aria from '../aria'; import * as blockSvgUtils from '../block_svg_utilities'; -FunctionStubber.getInstance().registerInitializationStub((block) => { - const svgPath = block.getFocusableElement(); - aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); - aria.setRole(svgPath, aria.Role.TREEITEM); - aria.setState(svgPath, aria.State.LABEL, blockSvgUtils.computeBlockAriaLabel(block)); - svgPath.tabIndex = -1; - (block as any).currentConnectionCandidate = null; -}, 'doInit_', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - block.workspace - .getTopBlocks(false) - .forEach((block) => blockSvgUtils.recomputeAriaTreeItemDetailsRecursively(block)); -}, 'setParent', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - (block as any).currentConnectionCandidate = - // @ts-expect-error Access to private property dragStrategy. - block.dragStrategy.connectionCandidate?.neighbour ?? null; - blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); -}, 'startDrag', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block, newLoc: Blockly.utils.Coordinate) => { - (block as any).currentConnectionCandidate = - // @ts-expect-error Access to private property dragStrategy. - block.dragStrategy.connectionCandidate?.neighbour ?? null; - blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); -}, 'drag', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - (block as any).currentConnectionCandidate = null; - blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); -}, 'endDrag', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true); -}, 'revertDrag', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - aria.setState(block.getFocusableElement(), aria.State.SELECTED, true); -}, 'onNodeFocus', Blockly.BlockSvg.prototype); - -FunctionStubber.getInstance().registerMethodStub((block) => { - aria.setState(block.getFocusableElement(), aria.State.SELECTED, false); -}, 'onNodeBlur', Blockly.BlockSvg.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (block) => { + const svgPath = block.getFocusableElement(); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, aria.Role.TREEITEM); + aria.setState( + svgPath, + aria.State.LABEL, + blockSvgUtils.computeBlockAriaLabel(block), + ); + svgPath.tabIndex = -1; + (block as any).currentConnectionCandidate = null; + }, + 'doInit_', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + block.workspace + .getTopBlocks(false) + .forEach((block) => + blockSvgUtils.recomputeAriaTreeItemDetailsRecursively(block), + ); + }, + 'setParent', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + (block as any).currentConnectionCandidate = + // @ts-expect-error Access to private property dragStrategy. + block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); + }, + 'startDrag', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block, newLoc: Blockly.utils.Coordinate) => { + (block as any).currentConnectionCandidate = + // @ts-expect-error Access to private property dragStrategy. + block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); + }, + 'drag', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + (block as any).currentConnectionCandidate = null; + blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); + }, + 'endDrag', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true); + }, + 'revertDrag', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + aria.setState(block.getFocusableElement(), aria.State.SELECTED, true); + }, + 'onNodeFocus', + Blockly.BlockSvg.prototype, +); + +FunctionStubber.getInstance().registerMethodStub( + (block) => { + aria.setState(block.getFocusableElement(), aria.State.SELECTED, false); + }, + 'onNodeBlur', + Blockly.BlockSvg.prototype, +); diff --git a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts index f4c3d26b..09fd17ee 100644 --- a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts @@ -3,20 +3,27 @@ import * as Blockly from 'blockly/core'; import * as aria from '../aria'; import * as toolboxUtils from '../toolbox_utilities'; -FunctionStubber.getInstance().registerInitializationStub((category) => { - const element = category.getFocusableElement(); - aria.setRole(element, aria.Role.GROUP); +FunctionStubber.getInstance().registerInitializationStub( + (category) => { + const element = category.getFocusableElement(); + aria.setRole(element, aria.Role.GROUP); - // Ensure this group has properly set children. - const selectableChildren = - category.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildIds = selectableChildren.map( - (selectable) => selectable.getFocusableElement().id, - ); - aria.setState( - element, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - toolboxUtils.recomputeAriaOwnersInToolbox(category.getFocusableTree() as Blockly.Toolbox); -}, 'init', Blockly.CollapsibleToolboxCategory.prototype); + // Ensure this group has properly set children. + const selectableChildren = + category.getChildToolboxItems().filter((item) => item.isSelectable()) ?? + null; + const focusableChildIds = selectableChildren.map( + (selectable) => selectable.getFocusableElement().id, + ); + aria.setState( + element, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + toolboxUtils.recomputeAriaOwnersInToolbox( + category.getFocusableTree() as Blockly.Toolbox, + ); + }, + 'init', + Blockly.CollapsibleToolboxCategory.prototype, +); diff --git a/src/screenreader/stuboverrides/override_comment_icon.ts b/src/screenreader/stuboverrides/override_comment_icon.ts index 7f99cc97..842096cd 100644 --- a/src/screenreader/stuboverrides/override_comment_icon.ts +++ b/src/screenreader/stuboverrides/override_comment_icon.ts @@ -2,11 +2,15 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', - ); -}, 'initView', Blockly.icons.CommentIcon.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); + }, + 'initView', + Blockly.icons.CommentIcon.prototype, +); diff --git a/src/screenreader/stuboverrides/override_field.ts b/src/screenreader/stuboverrides/override_field.ts index f4f82127..cb95af9b 100644 --- a/src/screenreader/stuboverrides/override_field.ts +++ b/src/screenreader/stuboverrides/override_field.ts @@ -2,9 +2,13 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((field) => { - // The text itself is presentation since it's represented through the - // block's ARIA label. - // @ts-expect-error Access to private property getTextElement. - aria.setState(field.getTextElement(), aria.State.HIDDEN, true); -}, 'createTextElement_', Blockly.Field.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (field) => { + // The text itself is presentation since it's represented through the + // block's ARIA label. + // @ts-expect-error Access to private property getTextElement. + aria.setState(field.getTextElement(), aria.State.HIDDEN, true); + }, + 'createTextElement_', + Blockly.Field.prototype, +); diff --git a/src/screenreader/stuboverrides/override_field_checkbox.ts b/src/screenreader/stuboverrides/override_field_checkbox.ts index bc614b0b..021d174c 100644 --- a/src/screenreader/stuboverrides/override_field_checkbox.ts +++ b/src/screenreader/stuboverrides/override_field_checkbox.ts @@ -2,12 +2,16 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((fieldCheckbox) => { - const element = fieldCheckbox.getFocusableElement(); - aria.setRole(element, aria.Role.CHECKBOX); - aria.setState( - element, - aria.State.LABEL, - fieldCheckbox.name ? `Checkbox ${fieldCheckbox.name}` : 'Checkbox', - ); -}, 'initView', Blockly.FieldCheckbox.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldCheckbox) => { + const element = fieldCheckbox.getFocusableElement(); + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + fieldCheckbox.name ? `Checkbox ${fieldCheckbox.name}` : 'Checkbox', + ); + }, + 'initView', + Blockly.FieldCheckbox.prototype, +); diff --git a/src/screenreader/stuboverrides/override_field_dropdown.ts b/src/screenreader/stuboverrides/override_field_dropdown.ts index f2c2c92b..c1711860 100644 --- a/src/screenreader/stuboverrides/override_field_dropdown.ts +++ b/src/screenreader/stuboverrides/override_field_dropdown.ts @@ -2,12 +2,16 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((fieldDropdown) => { - const element = fieldDropdown.getFocusableElement(); - aria.setRole(element, aria.Role.LISTBOX); - aria.setState( - element, - aria.State.LABEL, - fieldDropdown.name ? `Item ${fieldDropdown.name}` : 'Item', - ); -}, 'initView', Blockly.FieldDropdown.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldDropdown) => { + const element = fieldDropdown.getFocusableElement(); + aria.setRole(element, aria.Role.LISTBOX); + aria.setState( + element, + aria.State.LABEL, + fieldDropdown.name ? `Item ${fieldDropdown.name}` : 'Item', + ); + }, + 'initView', + Blockly.FieldDropdown.prototype, +); diff --git a/src/screenreader/stuboverrides/override_field_image.ts b/src/screenreader/stuboverrides/override_field_image.ts index 0711be29..628da173 100644 --- a/src/screenreader/stuboverrides/override_field_image.ts +++ b/src/screenreader/stuboverrides/override_field_image.ts @@ -2,12 +2,16 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((fieldImage) => { - const element = fieldImage.getFocusableElement(); - aria.setRole(element, aria.Role.IMAGE); - aria.setState( - element, - aria.State.LABEL, - fieldImage.name ? `Image ${fieldImage.name}` : 'Image', - ); -}, 'initView', Blockly.FieldImage.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldImage) => { + const element = fieldImage.getFocusableElement(); + aria.setRole(element, aria.Role.IMAGE); + aria.setState( + element, + aria.State.LABEL, + fieldImage.name ? `Image ${fieldImage.name}` : 'Image', + ); + }, + 'initView', + Blockly.FieldImage.prototype, +); diff --git a/src/screenreader/stuboverrides/override_field_input.ts b/src/screenreader/stuboverrides/override_field_input.ts index 3206237b..cb6dba3d 100644 --- a/src/screenreader/stuboverrides/override_field_input.ts +++ b/src/screenreader/stuboverrides/override_field_input.ts @@ -4,16 +4,24 @@ import * as aria from '../aria'; // Note: These can be consolidated to FieldInput, but that's not exported so it // has to be overwritten on a per-field basis. -FunctionStubber.getInstance().registerInitializationStub((fieldNumber) => { - initializeFieldInput(fieldNumber); -}, 'init', Blockly.FieldNumber.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldNumber) => { + initializeFieldInput(fieldNumber); + }, + 'init', + Blockly.FieldNumber.prototype, +); -FunctionStubber.getInstance().registerInitializationStub((fieldTextInput) => { - initializeFieldInput(fieldTextInput); -}, 'init', Blockly.FieldTextInput.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldTextInput) => { + initializeFieldInput(fieldTextInput); + }, + 'init', + Blockly.FieldTextInput.prototype, +); function initializeFieldInput( - fieldInput: Blockly.FieldNumber | Blockly.FieldTextInput + fieldInput: Blockly.FieldNumber | Blockly.FieldTextInput, ): void { const element = fieldInput.getFocusableElement(); aria.setRole(element, aria.Role.TEXTBOX); diff --git a/src/screenreader/stuboverrides/override_field_label.ts b/src/screenreader/stuboverrides/override_field_label.ts index a5f5e202..7e8ec700 100644 --- a/src/screenreader/stuboverrides/override_field_label.ts +++ b/src/screenreader/stuboverrides/override_field_label.ts @@ -2,10 +2,16 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((fieldLabel) => { - // There's no additional semantic meaning needed for a label; the aria-label - // should be sufficient for context. - aria.setState( - fieldLabel.getFocusableElement(), aria.State.LABEL, fieldLabel.getText() - ); -}, 'initView', Blockly.FieldLabel.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (fieldLabel) => { + // There's no additional semantic meaning needed for a label; the aria-label + // should be sufficient for context. + aria.setState( + fieldLabel.getFocusableElement(), + aria.State.LABEL, + fieldLabel.getText(), + ); + }, + 'initView', + Blockly.FieldLabel.prototype, +); diff --git a/src/screenreader/stuboverrides/override_flyout_button.ts b/src/screenreader/stuboverrides/override_flyout_button.ts index dc0e7e03..80ff989e 100644 --- a/src/screenreader/stuboverrides/override_flyout_button.ts +++ b/src/screenreader/stuboverrides/override_flyout_button.ts @@ -2,8 +2,12 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((flyoutButton) => { - const element = flyoutButton.getFocusableElement(); - aria.setRole(element, aria.Role.BUTTON); - aria.setState(element, aria.State.LABEL, 'Button'); -}, 'updateTransform', Blockly.FlyoutButton.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (flyoutButton) => { + const element = flyoutButton.getFocusableElement(); + aria.setRole(element, aria.Role.BUTTON); + aria.setState(element, aria.State.LABEL, 'Button'); + }, + 'updateTransform', + Blockly.FlyoutButton.prototype, +); diff --git a/src/screenreader/stuboverrides/override_icon.ts b/src/screenreader/stuboverrides/override_icon.ts index aa4e35a6..677644b7 100644 --- a/src/screenreader/stuboverrides/override_icon.ts +++ b/src/screenreader/stuboverrides/override_icon.ts @@ -2,8 +2,12 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((icon) => { - const element = icon.getFocusableElement(); - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Icon'); -}, 'initView', Blockly.icons.Icon.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (icon) => { + const element = icon.getFocusableElement(); + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Icon'); + }, + 'initView', + Blockly.icons.Icon.prototype, +); diff --git a/src/screenreader/stuboverrides/override_mutator_icon.ts b/src/screenreader/stuboverrides/override_mutator_icon.ts index e0ca8878..276e2083 100644 --- a/src/screenreader/stuboverrides/override_mutator_icon.ts +++ b/src/screenreader/stuboverrides/override_mutator_icon.ts @@ -2,11 +2,15 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', - ); -}, 'initView', Blockly.icons.MutatorIcon.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); + }, + 'initView', + Blockly.icons.MutatorIcon.prototype, +); diff --git a/src/screenreader/stuboverrides/override_rendered_connection.ts b/src/screenreader/stuboverrides/override_rendered_connection.ts index 15779a07..e4277686 100644 --- a/src/screenreader/stuboverrides/override_rendered_connection.ts +++ b/src/screenreader/stuboverrides/override_rendered_connection.ts @@ -2,12 +2,16 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((connection) => { - // This is a later initialization than most components but it's likely - // adequate since the creation of RenderedConnection's focusable element is - // part of the block rendering lifecycle (so the class itself isn't even aware - // when its element exists). - const element = connection.getFocusableElement(); - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Open connection'); -}, 'highlight', Blockly.RenderedConnection.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (connection) => { + // This is a later initialization than most components but it's likely + // adequate since the creation of RenderedConnection's focusable element is + // part of the block rendering lifecycle (so the class itself isn't even aware + // when its element exists). + const element = connection.getFocusableElement(); + aria.setRole(element, aria.Role.FIGURE); + aria.setState(element, aria.State.LABEL, 'Open connection'); + }, + 'highlight', + Blockly.RenderedConnection.prototype, +); diff --git a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts index 0a87af6b..b338d82a 100644 --- a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts +++ b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts @@ -2,8 +2,12 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((comment) => { - const element = comment.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); -}, 'addModelUpdateBindings', Blockly.comments.RenderedWorkspaceComment.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (comment) => { + const element = comment.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); + }, + 'addModelUpdateBindings', + Blockly.comments.RenderedWorkspaceComment.prototype, +); diff --git a/src/screenreader/stuboverrides/override_toolbox.ts b/src/screenreader/stuboverrides/override_toolbox.ts index d746682d..a2a182d6 100644 --- a/src/screenreader/stuboverrides/override_toolbox.ts +++ b/src/screenreader/stuboverrides/override_toolbox.ts @@ -2,6 +2,10 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((toolbox) => { - aria.setRole(toolbox.getFocusableElement(), aria.Role.TREE); -}, 'init', Blockly.Toolbox.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (toolbox) => { + aria.setRole(toolbox.getFocusableElement(), aria.Role.TREE); + }, + 'init', + Blockly.Toolbox.prototype, +); diff --git a/src/screenreader/stuboverrides/override_toolbox_category.ts b/src/screenreader/stuboverrides/override_toolbox_category.ts index a24b88a9..b6cfa15e 100644 --- a/src/screenreader/stuboverrides/override_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_toolbox_category.ts @@ -4,7 +4,13 @@ import * as aria from '../aria'; import * as toolboxUtils from '../toolbox_utilities'; // TODO: Reimplement selected for items and expanded for categories, and levels. -FunctionStubber.getInstance().registerInitializationStub((category) => { - aria.setRole(category.getFocusableElement(), aria.Role.TREEITEM); - toolboxUtils.recomputeAriaOwnersInToolbox(category.getFocusableTree() as Blockly.Toolbox); -}, 'init', Blockly.ToolboxCategory.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (category) => { + aria.setRole(category.getFocusableElement(), aria.Role.TREEITEM); + toolboxUtils.recomputeAriaOwnersInToolbox( + category.getFocusableTree() as Blockly.Toolbox, + ); + }, + 'init', + Blockly.ToolboxCategory.prototype, +); diff --git a/src/screenreader/stuboverrides/override_toolbox_separator.ts b/src/screenreader/stuboverrides/override_toolbox_separator.ts index e140af60..10ec850c 100644 --- a/src/screenreader/stuboverrides/override_toolbox_separator.ts +++ b/src/screenreader/stuboverrides/override_toolbox_separator.ts @@ -3,7 +3,13 @@ import * as Blockly from 'blockly/core'; import * as aria from '../aria'; import * as toolboxUtils from '../toolbox_utilities'; -FunctionStubber.getInstance().registerInitializationStub((separator) => { - aria.setRole(separator.getFocusableElement(), aria.Role.SEPARATOR); - toolboxUtils.recomputeAriaOwnersInToolbox(separator.getFocusableTree() as Blockly.Toolbox); -}, 'init', Blockly.ToolboxSeparator.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (separator) => { + aria.setRole(separator.getFocusableElement(), aria.Role.SEPARATOR); + toolboxUtils.recomputeAriaOwnersInToolbox( + separator.getFocusableTree() as Blockly.Toolbox, + ); + }, + 'init', + Blockly.ToolboxSeparator.prototype, +); diff --git a/src/screenreader/stuboverrides/override_warning_icon.ts b/src/screenreader/stuboverrides/override_warning_icon.ts index 588e4f7f..0d6e45ea 100644 --- a/src/screenreader/stuboverrides/override_warning_icon.ts +++ b/src/screenreader/stuboverrides/override_warning_icon.ts @@ -2,11 +2,15 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', - ); -}, 'initView', Blockly.icons.WarningIcon.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (icon) => { + const element = icon.getFocusableElement(); + aria.setState( + element, + aria.State.LABEL, + icon.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); + }, + 'initView', + Blockly.icons.WarningIcon.prototype, +); diff --git a/src/screenreader/stuboverrides/override_workspace_svg.ts b/src/screenreader/stuboverrides/override_workspace_svg.ts index af16f1d5..3bf125b7 100644 --- a/src/screenreader/stuboverrides/override_workspace_svg.ts +++ b/src/screenreader/stuboverrides/override_workspace_svg.ts @@ -2,19 +2,23 @@ import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; -FunctionStubber.getInstance().registerInitializationStub((workspace) => { - const element = workspace.getFocusableElement(); - aria.setRole(element, aria.Role.TREE); - let ariaLabel = null; - // @ts-expect-error Access to private property injectionDiv. - if (workspace.injectionDiv) { - ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; - } else if (workspace.isFlyout) { - ariaLabel = 'Flyout'; - } else if (workspace.isMutator) { - ariaLabel = 'Mutator'; - } else { - throw new Error('Cannot determine ARIA label for workspace.'); - } - aria.setState(element, aria.State.LABEL, ariaLabel); -}, 'createDom', Blockly.WorkspaceSvg.prototype); +FunctionStubber.getInstance().registerInitializationStub( + (workspace) => { + const element = workspace.getFocusableElement(); + aria.setRole(element, aria.Role.TREE); + let ariaLabel = null; + // @ts-expect-error Access to private property injectionDiv. + if (workspace.injectionDiv) { + ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; + } else if (workspace.isFlyout) { + ariaLabel = 'Flyout'; + } else if (workspace.isMutator) { + ariaLabel = 'Mutator'; + } else { + throw new Error('Cannot determine ARIA label for workspace.'); + } + aria.setState(element, aria.State.LABEL, ariaLabel); + }, + 'createDom', + Blockly.WorkspaceSvg.prototype, +); diff --git a/src/screenreader/toolbox_utilities.ts b/src/screenreader/toolbox_utilities.ts index 53fc3a6d..89c1335d 100644 --- a/src/screenreader/toolbox_utilities.ts +++ b/src/screenreader/toolbox_utilities.ts @@ -19,4 +19,4 @@ export function recomputeAriaOwnersInToolbox(toolbox: Blockly.Toolbox) { focusableChildElems.forEach((elem, index) => aria.setState(elem, aria.State.POSINSET, index + 1), ); -}; +} From b68ba5030f61a97420f0bf486f6cd71bf97e8853 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 4 Aug 2025 19:21:32 +0000 Subject: [PATCH 09/17] fix: Fix failing tests. This adds the missing div needed for ARIA 'announcements'. --- test/webdriverio/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/webdriverio/index.ts b/test/webdriverio/index.ts index f6b67963..7a328f8a 100644 --- a/test/webdriverio/index.ts +++ b/test/webdriverio/index.ts @@ -9,6 +9,7 @@ import * as Blockly from 'blockly'; import 'blockly/blocks'; import {installAllBlocks as installColourBlocks} from '@blockly/field-colour'; import {KeyboardNavigation} from '../../src/index'; +import * as aria from '../../src/screenreader/aria'; import {registerFlyoutCursor} from '../../src/flyout_cursor'; import {registerNavigationDeferringToolbox} from '../../src/navigation_deferring_toolbox'; // @ts-expect-error No types in js file @@ -86,6 +87,15 @@ function createWorkspace(): Blockly.WorkspaceSvg { registerNavigationDeferringToolbox(); const workspace = Blockly.inject(blocklyDiv, injectOptions); + const injectionDiv = document.querySelector('.injectionDiv'); + if (!injectionDiv) { + throw new Error('Expected injection div to exist after injection.'); + } + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + injectionDiv.appendChild(ariaAnnouncementSpan); + Blockly.ContextMenuItems.registerCommentOptions(); new KeyboardNavigation(workspace); From cc848aac0dcf439d6c9406472ab9f5008e98a8c4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 4 Aug 2025 19:33:07 +0000 Subject: [PATCH 10/17] fix: New Mocha tests. This removes unstubbing since it can't really work well, anyway, which fixes the Mocha tests that validate initialization and disposal for the KeyboardNavigation class. --- src/navigation_controller.ts | 2 -- src/screenreader/function_stubber_registry.ts | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 2dfe3e77..45424523 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -303,7 +303,5 @@ export class NavigationController { } this.removeShortcutHandlers(); this.navigation.dispose(); - - FunctionStubber.getInstance().unstubPrototypes(); } } diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts index f8e74833..73193dc2 100644 --- a/src/screenreader/function_stubber_registry.ts +++ b/src/screenreader/function_stubber_registry.ts @@ -44,17 +44,6 @@ class Registration { return result; }; } - - unstubPrototype(): void { - if (this.oldMethod) { - throw new Error( - `Function is not currently stubbed: ${this.methodNameToOverride}.`, - ); - } - const genericPrototype = this.classPrototype as any; - genericPrototype[this.methodNameToOverride] = this.oldMethod; - this.oldMethod = null; - } } export class FunctionStubber { @@ -104,13 +93,6 @@ export class FunctionStubber { this.registrations.forEach((registration) => registration.stubPrototype()); } - unstubPrototypes() { - this.registrations.forEach((registration) => - registration.unstubPrototype(), - ); - this.isFinalized = false; - } - private static instance: FunctionStubber | null = null; static getInstance(): FunctionStubber { From a94f0373ff32bf4b2e2680150052b4e433626c0c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 4 Aug 2025 20:19:19 +0000 Subject: [PATCH 11/17] chore: Add docs & remove unnecessary bits. This addresses all lint warnings. --- src/screenreader/aria.ts | 85 ++++++++++++++----- src/screenreader/block_svg_utilities.ts | 80 +++++++++++++++-- src/screenreader/function_stubber_registry.ts | 71 ++++++++++++++++ .../stuboverrides/override_block_svg.ts | 30 +++---- .../override_collapsible_toolbox_category.ts | 6 ++ .../stuboverrides/override_comment_icon.ts | 6 ++ .../stuboverrides/override_field.ts | 6 ++ .../stuboverrides/override_field_checkbox.ts | 6 ++ .../stuboverrides/override_field_dropdown.ts | 6 ++ .../stuboverrides/override_field_image.ts | 6 ++ .../stuboverrides/override_field_input.ts | 6 ++ .../stuboverrides/override_field_label.ts | 6 ++ .../stuboverrides/override_flyout_button.ts | 6 ++ .../stuboverrides/override_icon.ts | 6 ++ .../stuboverrides/override_mutator_icon.ts | 6 ++ .../override_rendered_connection.ts | 6 ++ .../override_rendered_workspace_comment.ts | 6 ++ .../stuboverrides/override_toolbox.ts | 6 ++ .../override_toolbox_category.ts | 6 ++ .../override_toolbox_separator.ts | 6 ++ .../stuboverrides/override_warning_icon.ts | 6 ++ .../stuboverrides/override_workspace_svg.ts | 6 ++ src/screenreader/toolbox_utilities.ts | 14 +++ 23 files changed, 345 insertions(+), 43 deletions(-) diff --git a/src/screenreader/aria.ts b/src/screenreader/aria.ts index 016a3d3d..975c4857 100644 --- a/src/screenreader/aria.ts +++ b/src/screenreader/aria.ts @@ -7,57 +7,43 @@ const ARIA_PREFIX = 'aria-'; const ROLE_ATTRIBUTE = 'role'; -// TODO: Finalize this. +/** Represents an ARIA role that an element may have. */ export enum Role { - GRID = 'grid', - GRIDCELL = 'gridcell', GROUP = 'group', LISTBOX = 'listbox', - MENU = 'menu', - MENUITEM = 'menuitem', - MENUITEMCHECKBOX = 'menuitemcheckbox', - OPTION = 'option', PRESENTATION = 'presentation', - ROW = 'row', TREE = 'tree', TREEITEM = 'treeitem', SEPARATOR = 'separator', - STATUS = 'status', - REGION = 'region', IMAGE = 'image', FIGURE = 'figure', BUTTON = 'button', CHECKBOX = 'checkbox', TEXTBOX = 'textbox', - APPLICATION = 'application', } -// TODO: Finalize this. +/** Represents ARIA-specific state that can be configured for an element. */ export enum State { - ACTIVEDESCENDANT = 'activedescendant', - COLCOUNT = 'colcount', - DISABLED = 'disabled', - EXPANDED = 'expanded', - INVALID = 'invalid', LABEL = 'label', - LABELLEDBY = 'labelledby', LEVEL = 'level', - ORIENTATION = 'orientation', POSINSET = 'posinset', - ROWCOUNT = 'rowcount', SELECTED = 'selected', SETSIZE = 'setsize', - VALUEMAX = 'valuemax', - VALUEMIN = 'valuemin', LIVE = 'live', HIDDEN = 'hidden', ROLEDESCRIPTION = 'roledescription', - ATOMIC = 'atomic', OWNS = 'owns', } let isMutatingAriaProperty = false; +/** + * Updates the specific role for the specified element. + * + * @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) { isMutatingAriaProperty = true; if (roleName) { @@ -66,6 +52,13 @@ export function setRole(element: Element, roleName: Role | null) { isMutatingAriaProperty = false; } +/** + * 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 The element from which to retrieve its ARIA role. + * @returns The ARIA role of the element, or null if undefined or unknown. + */ 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. @@ -76,6 +69,18 @@ export function getRole(element: Element): Role | null { return null; } +/** + * Sets the specified ARIA state by its name and value for the specified + * element. + * + * 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, stateName: State, @@ -90,11 +95,39 @@ export function setState( isMutatingAriaProperty = false; } +/** + * 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); } +/** + * Softly 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 should not interrupt + * existing announcements (that is, this is what's considered a polite + * announcement). + * + * 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 only be used 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 politely read to the user. + */ export function announceDynamicAriaState(text: string) { const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); if (!ariaAnnouncementSpan) { @@ -103,6 +136,12 @@ export function announceDynamicAriaState(text: string) { ariaAnnouncementSpan.innerHTML = text; } +/** + * Determines whether an ARIA property is in the process of being changed. + * + * @returns Returns whether an ARIA property is changing for any element, + * specifically via setRole() or stateState(). + */ export function isCurrentlyMutatingAriaProperty(): boolean { return isMutatingAriaProperty; } diff --git a/src/screenreader/block_svg_utilities.ts b/src/screenreader/block_svg_utilities.ts index 6dcc86c5..15b5fafc 100644 --- a/src/screenreader/block_svg_utilities.ts +++ b/src/screenreader/block_svg_utilities.ts @@ -1,6 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import * as Blockly from 'blockly/core'; import * as aria from './aria'; +/** + * Computes the human-readable ARIA label for the specified block. + * + * @param block The block whose label should be computed. + * @returns A human-readable ARIA label/representation for the block. + */ export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { // Guess the block's aria label based on its field labels. if (block.isShadow()) { @@ -49,12 +61,27 @@ function computeLevelInWorkspace(block: Blockly.BlockSvg): number { return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0; } -// TODO: Do this efficiently (probably centrally). -export function recomputeAriaTreeItemDetailsRecursively( - block: Blockly.BlockSvg, +/** + * Recomputes all BlockSvg ARIA tree structures in the workspace. + * + * This is a fairly expensive operation and should ideally only be performed + * when a block structure or relationship change has been made. + * + * @param workspace The workspace whose top-level blocks may need a tree + * structure recomputation. + */ +export function recomputeAllWorkspaceAriaTrees( + workspace: Blockly.WorkspaceSvg, ) { + // TODO: Do this efficiently (probably increementally). + workspace + .getTopBlocks(false) + .forEach((block) => recomputeAriaTreeItemDetailsRecursively(block)); +} + +function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) { const elem = block.getFocusableElement(); - const connection = (block as any).currentConnectionCandidate; + const connection = getCurrentConnectionCandidate(block); let childPosition: number; let parentsChildCount: number; let hierarchyDepth: number; @@ -94,13 +121,26 @@ export function recomputeAriaTreeItemDetailsRecursively( .forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); } +/** + * 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 block The block whose dynamic state should maybe be announced. + * @param isMoving Whether the specified block is currently being moved. + * @param isCanceled Whether the previous movement operation has been canceled. + * @param newLoc The new location the block is moving to (if unconstrained). + */ export function announceDynamicAriaStateForBlock( block: Blockly.BlockSvg, isMoving: boolean, isCanceled: boolean, newLoc?: Blockly.utils.Coordinate, ) { - const connection = (block as any).currentConnectionCandidate; + const connection = getCurrentConnectionCandidate(block); if (isCanceled) { aria.announceDynamicAriaState('Canceled movement'); return; @@ -132,3 +172,33 @@ export function announceDynamicAriaStateForBlock( ); } } + +interface ConnectionCandidateHolder { + currentConnectionCandidate: Blockly.RenderedConnection | null; +} + +function getCurrentConnectionCandidate( + block: Blockly.BlockSvg, +): Blockly.RenderedConnection | null { + const connectionHolder = block as unknown as ConnectionCandidateHolder; + return connectionHolder.currentConnectionCandidate; +} + +/** + * Updates the current connection candidate for the specified block (that is, + * the connection the block is being connected to). + * + * This corresponds to a temporary property used when determining specifics of + * a block's location when being moved. + * + * @param block The block which may have a new connection candidate. + * @param connection The latest connection candidate for the block, or null if + * none. + */ +export function setCurrentConnectionCandidate( + block: Blockly.BlockSvg, + connection: Blockly.RenderedConnection | null, +) { + const connectionHolder = block as unknown as ConnectionCandidateHolder; + connectionHolder.currentConnectionCandidate = connection; +} diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts index 73193dc2..593c6734 100644 --- a/src/screenreader/function_stubber_registry.ts +++ b/src/screenreader/function_stubber_registry.ts @@ -1,6 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A function callback used to run after an overridden stub method using + * FunctionStubber. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type StubCallback = (instance: T, ...args: any) => void; class Registration { + // eslint-disable-next-line @typescript-eslint/no-explicit-any private oldMethod: ((...args: any) => any) | null = null; constructor( @@ -17,13 +29,17 @@ class Registration { `Function is already stubbed: ${this.methodNameToOverride}.`, ); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const genericPrototype = this.classPrototype as any; const oldMethod = genericPrototype[this.methodNameToOverride] as ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any ) => any; this.oldMethod = oldMethod; // eslint-disable-next-line @typescript-eslint/no-this-alias const registration = this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any genericPrototype[this.methodNameToOverride] = function (...args: any): any { let stubsCalled = this._internalStubsCalled as | {[key: string]: boolean} @@ -46,10 +62,43 @@ class Registration { } } +/** + * Utility for augmenting a class's functionality by monkey-patching a + * function's prototype in order to call a custom function. + * + * Note that all custom functions are always run after the original function + * runs. This order cannot be configured, nor can the original function be + * disabled. + * + * There are two types of overrides possible: initialization via + * registerInitializationStub() and regular class methods via + * registerMethodStub(). + * + * Instances of this class should retrieved using getInstance(). + * + * IMPORTANT: In order for stubbing to work correctly, see the caveats of + * stubPrototypes(). + */ export class FunctionStubber { + // eslint-disable-next-line @typescript-eslint/no-explicit-any private registrations: Array> = []; private isFinalized = false; + /** + * Registers a new initialization stub. + * + * Initialization stub callbacks are only invoked once per instance of a given + * object, even if that function is called multiple times. This allows for + * methods called in a class's constructor to be used as a proxy for the + * constructor itself. + * + * This will throw an error if called after stubPrototypes() has been called. + * + * @param callback The function to run when the stubbed method executes for + * the first time. + * @param methodNameToOverride The name of the method to override. + * @param classPrototype The prototype of the class being stubbed. + */ registerInitializationStub( callback: StubCallback, methodNameToOverride: string, @@ -69,6 +118,18 @@ export class FunctionStubber { this.registrations.push(registration); } + /** + * Registers a new method stub. + * + * Method stub callbacks are invoked every time the overridden method is + * invoked. + * + * This will throw an error if called after stubPrototypes() has been called. + * + * @param callback The function to run when the stubbed method executes. + * @param methodNameToOverride The name of the method to override. + * @param classPrototype The prototype of the class being stubbed. + */ registerMethodStub( callback: StubCallback, methodNameToOverride: string, @@ -88,6 +149,15 @@ export class FunctionStubber { this.registrations.push(registration); } + /** + * Performs the actual monkey-patching to enable the custom registered + * callbacks from registerInitializationStub() and registerMethodStub() to + * work correctly. + * + * IMPORTANT: This must be called after all registration is completed, and + * before any of the stubbed classes are actually used. This cannot be undone + * (that is, there is no deregistration). + */ stubPrototypes() { this.isFinalized = true; this.registrations.forEach((registration) => registration.stubPrototype()); @@ -95,6 +165,7 @@ export class FunctionStubber { private static instance: FunctionStubber | null = null; + /** Returns the page-global instance of this FunctionStubber. */ static getInstance(): FunctionStubber { if (!FunctionStubber.instance) { FunctionStubber.instance = new FunctionStubber(); diff --git a/src/screenreader/stuboverrides/override_block_svg.ts b/src/screenreader/stuboverrides/override_block_svg.ts index 2ee07392..e8c7628e 100644 --- a/src/screenreader/stuboverrides/override_block_svg.ts +++ b/src/screenreader/stuboverrides/override_block_svg.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; @@ -14,29 +20,23 @@ FunctionStubber.getInstance().registerInitializationStub( blockSvgUtils.computeBlockAriaLabel(block), ); svgPath.tabIndex = -1; - (block as any).currentConnectionCandidate = null; + blockSvgUtils.setCurrentConnectionCandidate(block, null); }, 'doInit_', Blockly.BlockSvg.prototype, ); FunctionStubber.getInstance().registerMethodStub( - (block) => { - block.workspace - .getTopBlocks(false) - .forEach((block) => - blockSvgUtils.recomputeAriaTreeItemDetailsRecursively(block), - ); - }, + (block) => blockSvgUtils.recomputeAllWorkspaceAriaTrees(block.workspace), 'setParent', Blockly.BlockSvg.prototype, ); FunctionStubber.getInstance().registerMethodStub( (block) => { - (block as any).currentConnectionCandidate = - // @ts-expect-error Access to private property dragStrategy. - block.dragStrategy.connectionCandidate?.neighbour ?? null; + // @ts-expect-error Access to private property dragStrategy. + const candidate = block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.setCurrentConnectionCandidate(block, candidate); blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); }, 'startDrag', @@ -45,9 +45,9 @@ FunctionStubber.getInstance().registerMethodStub( FunctionStubber.getInstance().registerMethodStub( (block, newLoc: Blockly.utils.Coordinate) => { - (block as any).currentConnectionCandidate = - // @ts-expect-error Access to private property dragStrategy. - block.dragStrategy.connectionCandidate?.neighbour ?? null; + // @ts-expect-error Access to private property dragStrategy. + const candidate = block.dragStrategy.connectionCandidate?.neighbour ?? null; + blockSvgUtils.setCurrentConnectionCandidate(block, candidate); blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); }, 'drag', @@ -56,7 +56,7 @@ FunctionStubber.getInstance().registerMethodStub( FunctionStubber.getInstance().registerMethodStub( (block) => { - (block as any).currentConnectionCandidate = null; + blockSvgUtils.setCurrentConnectionCandidate(block, null); blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); }, 'endDrag', diff --git a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts index 09fd17ee..c853a349 100644 --- a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_comment_icon.ts b/src/screenreader/stuboverrides/override_comment_icon.ts index 842096cd..b9fdf372 100644 --- a/src/screenreader/stuboverrides/override_comment_icon.ts +++ b/src/screenreader/stuboverrides/override_comment_icon.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field.ts b/src/screenreader/stuboverrides/override_field.ts index cb95af9b..b0fcb1cc 100644 --- a/src/screenreader/stuboverrides/override_field.ts +++ b/src/screenreader/stuboverrides/override_field.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field_checkbox.ts b/src/screenreader/stuboverrides/override_field_checkbox.ts index 021d174c..9f2c7027 100644 --- a/src/screenreader/stuboverrides/override_field_checkbox.ts +++ b/src/screenreader/stuboverrides/override_field_checkbox.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field_dropdown.ts b/src/screenreader/stuboverrides/override_field_dropdown.ts index c1711860..3bd197ca 100644 --- a/src/screenreader/stuboverrides/override_field_dropdown.ts +++ b/src/screenreader/stuboverrides/override_field_dropdown.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field_image.ts b/src/screenreader/stuboverrides/override_field_image.ts index 628da173..ddace62d 100644 --- a/src/screenreader/stuboverrides/override_field_image.ts +++ b/src/screenreader/stuboverrides/override_field_image.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field_input.ts b/src/screenreader/stuboverrides/override_field_input.ts index cb6dba3d..6d20b2b6 100644 --- a/src/screenreader/stuboverrides/override_field_input.ts +++ b/src/screenreader/stuboverrides/override_field_input.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_field_label.ts b/src/screenreader/stuboverrides/override_field_label.ts index 7e8ec700..2063defe 100644 --- a/src/screenreader/stuboverrides/override_field_label.ts +++ b/src/screenreader/stuboverrides/override_field_label.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_flyout_button.ts b/src/screenreader/stuboverrides/override_flyout_button.ts index 80ff989e..4ae667a3 100644 --- a/src/screenreader/stuboverrides/override_flyout_button.ts +++ b/src/screenreader/stuboverrides/override_flyout_button.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_icon.ts b/src/screenreader/stuboverrides/override_icon.ts index 677644b7..6e83c23e 100644 --- a/src/screenreader/stuboverrides/override_icon.ts +++ b/src/screenreader/stuboverrides/override_icon.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_mutator_icon.ts b/src/screenreader/stuboverrides/override_mutator_icon.ts index 276e2083..5ef23e80 100644 --- a/src/screenreader/stuboverrides/override_mutator_icon.ts +++ b/src/screenreader/stuboverrides/override_mutator_icon.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_rendered_connection.ts b/src/screenreader/stuboverrides/override_rendered_connection.ts index e4277686..191cdec4 100644 --- a/src/screenreader/stuboverrides/override_rendered_connection.ts +++ b/src/screenreader/stuboverrides/override_rendered_connection.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts index b338d82a..5e5b0065 100644 --- a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts +++ b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_toolbox.ts b/src/screenreader/stuboverrides/override_toolbox.ts index a2a182d6..1a401d17 100644 --- a/src/screenreader/stuboverrides/override_toolbox.ts +++ b/src/screenreader/stuboverrides/override_toolbox.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_toolbox_category.ts b/src/screenreader/stuboverrides/override_toolbox_category.ts index b6cfa15e..f0fab577 100644 --- a/src/screenreader/stuboverrides/override_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_toolbox_category.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_toolbox_separator.ts b/src/screenreader/stuboverrides/override_toolbox_separator.ts index 10ec850c..bbcb2f80 100644 --- a/src/screenreader/stuboverrides/override_toolbox_separator.ts +++ b/src/screenreader/stuboverrides/override_toolbox_separator.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_warning_icon.ts b/src/screenreader/stuboverrides/override_warning_icon.ts index 0d6e45ea..d1b558a7 100644 --- a/src/screenreader/stuboverrides/override_warning_icon.ts +++ b/src/screenreader/stuboverrides/override_warning_icon.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/stuboverrides/override_workspace_svg.ts b/src/screenreader/stuboverrides/override_workspace_svg.ts index 3bf125b7..c8508e02 100644 --- a/src/screenreader/stuboverrides/override_workspace_svg.ts +++ b/src/screenreader/stuboverrides/override_workspace_svg.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {FunctionStubber} from '../function_stubber_registry'; import * as Blockly from 'blockly/core'; import * as aria from '../aria'; diff --git a/src/screenreader/toolbox_utilities.ts b/src/screenreader/toolbox_utilities.ts index 89c1335d..ac9c3515 100644 --- a/src/screenreader/toolbox_utilities.ts +++ b/src/screenreader/toolbox_utilities.ts @@ -1,6 +1,20 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import * as Blockly from 'blockly/core'; import * as aria from './aria'; +/** + * Recomputes ARIA tree ownership relationships for all of the specified + * Toolbox's categories and items. + * + * This should only be done when the Toolbox's contents have changed. + * + * @param toolbox The toolbox whose ARIA tree should be recomputed. + */ export function recomputeAriaOwnersInToolbox(toolbox: Blockly.Toolbox) { const focusable = toolbox.getFocusableElement(); const selectableChildren = From a6528a11e3180de3dadb3e6a370a836fecf92da3 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 4 Aug 2025 20:43:48 +0000 Subject: [PATCH 12/17] fix: Make stubbing work with minification. This updates stubbing to use direct function references rather than name strings since the code references will be correctly updated during minification. This also makes it a bit more explicit when non-public functions are being stubbed, and it slightly simplifies stubbing logic. --- src/screenreader/function_stubber_registry.ts | 40 +++++++++---------- .../stuboverrides/override_block_svg.ts | 17 ++++---- .../override_collapsible_toolbox_category.ts | 2 +- .../stuboverrides/override_comment_icon.ts | 2 +- .../stuboverrides/override_field.ts | 3 +- .../stuboverrides/override_field_checkbox.ts | 2 +- .../stuboverrides/override_field_dropdown.ts | 2 +- .../stuboverrides/override_field_image.ts | 2 +- .../stuboverrides/override_field_input.ts | 4 +- .../stuboverrides/override_field_label.ts | 2 +- .../stuboverrides/override_flyout_button.ts | 3 +- .../stuboverrides/override_icon.ts | 2 +- .../stuboverrides/override_mutator_icon.ts | 2 +- .../override_rendered_connection.ts | 2 +- .../override_rendered_workspace_comment.ts | 3 +- .../stuboverrides/override_toolbox.ts | 2 +- .../override_toolbox_category.ts | 2 +- .../override_toolbox_separator.ts | 2 +- .../stuboverrides/override_warning_icon.ts | 2 +- .../stuboverrides/override_workspace_svg.ts | 2 +- 20 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts index 593c6734..466feecb 100644 --- a/src/screenreader/function_stubber_registry.ts +++ b/src/screenreader/function_stubber_registry.ts @@ -11,36 +11,34 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type StubCallback = (instance: T, ...args: any) => void; +/** The type representation of a generic function that can be stubbed. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GenericFunction = (...args: any) => any; + class Registration { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private oldMethod: ((...args: any) => any) | null = null; + private oldMethod: GenericFunction | null = null; constructor( readonly callback: StubCallback, - readonly methodNameToOverride: string, + readonly methodToOverride: GenericFunction, readonly classPrototype: T, readonly ensureOneCall: boolean, ) {} stubPrototype(): void { - // TODO: Figure out how to make this work with minification. if (this.oldMethod) { throw new Error( - `Function is already stubbed: ${this.methodNameToOverride}.`, + `Function is already stubbed: ${this.methodToOverride.name}.`, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const genericPrototype = this.classPrototype as any; - const oldMethod = genericPrototype[this.methodNameToOverride] as ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => any; - this.oldMethod = oldMethod; + this.oldMethod = this.methodToOverride; // eslint-disable-next-line @typescript-eslint/no-this-alias const registration = this; + const methodNameToOverride = this.methodToOverride.name; // eslint-disable-next-line @typescript-eslint/no-explicit-any - genericPrototype[this.methodNameToOverride] = function (...args: any): any { + genericPrototype[methodNameToOverride] = function (...args: any): any { let stubsCalled = this._internalStubsCalled as | {[key: string]: boolean} | undefined; @@ -49,13 +47,13 @@ class Registration { this._internalStubsCalled = stubsCalled; } - const result = oldMethod.call(this, ...args); + const result = registration.methodToOverride.call(this, ...args); if ( !registration.ensureOneCall || - !stubsCalled[registration.methodNameToOverride] + !stubsCalled[registration.methodToOverride.name] ) { registration.callback(this as unknown as T, ...args); - stubsCalled[registration.methodNameToOverride] = true; + stubsCalled[registration.methodToOverride.name] = true; } return result; }; @@ -96,12 +94,12 @@ export class FunctionStubber { * * @param callback The function to run when the stubbed method executes for * the first time. - * @param methodNameToOverride The name of the method to override. + * @param methodToOverride The method within the prototype to override. * @param classPrototype The prototype of the class being stubbed. */ registerInitializationStub( callback: StubCallback, - methodNameToOverride: string, + methodToOverride: GenericFunction, classPrototype: T, ) { if (this.isFinalized) { @@ -111,7 +109,7 @@ export class FunctionStubber { } const registration = new Registration( callback, - methodNameToOverride, + methodToOverride, classPrototype, true, ); @@ -127,12 +125,12 @@ export class FunctionStubber { * This will throw an error if called after stubPrototypes() has been called. * * @param callback The function to run when the stubbed method executes. - * @param methodNameToOverride The name of the method to override. + * @param methodToOverride The method within the prototype to override. * @param classPrototype The prototype of the class being stubbed. */ registerMethodStub( callback: StubCallback, - methodNameToOverride: string, + methodToOverride: GenericFunction, classPrototype: T, ) { if (this.isFinalized) { @@ -142,7 +140,7 @@ export class FunctionStubber { } const registration = new Registration( callback, - methodNameToOverride, + methodToOverride, classPrototype, false, ); diff --git a/src/screenreader/stuboverrides/override_block_svg.ts b/src/screenreader/stuboverrides/override_block_svg.ts index e8c7628e..68ec0bda 100644 --- a/src/screenreader/stuboverrides/override_block_svg.ts +++ b/src/screenreader/stuboverrides/override_block_svg.ts @@ -22,13 +22,14 @@ FunctionStubber.getInstance().registerInitializationStub( svgPath.tabIndex = -1; blockSvgUtils.setCurrentConnectionCandidate(block, null); }, - 'doInit_', + // @ts-expect-error Access to protected property doInit_. + Blockly.BlockSvg.prototype.doInit_, Blockly.BlockSvg.prototype, ); FunctionStubber.getInstance().registerMethodStub( (block) => blockSvgUtils.recomputeAllWorkspaceAriaTrees(block.workspace), - 'setParent', + Blockly.BlockSvg.prototype.setParent, Blockly.BlockSvg.prototype, ); @@ -39,7 +40,7 @@ FunctionStubber.getInstance().registerMethodStub( blockSvgUtils.setCurrentConnectionCandidate(block, candidate); blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); }, - 'startDrag', + Blockly.BlockSvg.prototype.startDrag, Blockly.BlockSvg.prototype, ); @@ -50,7 +51,7 @@ FunctionStubber.getInstance().registerMethodStub( blockSvgUtils.setCurrentConnectionCandidate(block, candidate); blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); }, - 'drag', + Blockly.BlockSvg.prototype.drag, Blockly.BlockSvg.prototype, ); @@ -59,7 +60,7 @@ FunctionStubber.getInstance().registerMethodStub( blockSvgUtils.setCurrentConnectionCandidate(block, null); blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); }, - 'endDrag', + Blockly.BlockSvg.prototype.endDrag, Blockly.BlockSvg.prototype, ); @@ -67,7 +68,7 @@ FunctionStubber.getInstance().registerMethodStub( (block) => { blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true); }, - 'revertDrag', + Blockly.BlockSvg.prototype.revertDrag, Blockly.BlockSvg.prototype, ); @@ -75,7 +76,7 @@ FunctionStubber.getInstance().registerMethodStub( (block) => { aria.setState(block.getFocusableElement(), aria.State.SELECTED, true); }, - 'onNodeFocus', + Blockly.BlockSvg.prototype.onNodeFocus, Blockly.BlockSvg.prototype, ); @@ -83,6 +84,6 @@ FunctionStubber.getInstance().registerMethodStub( (block) => { aria.setState(block.getFocusableElement(), aria.State.SELECTED, false); }, - 'onNodeBlur', + Blockly.BlockSvg.prototype.onNodeBlur, Blockly.BlockSvg.prototype, ); diff --git a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts index c853a349..b7a06fd9 100644 --- a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts @@ -30,6 +30,6 @@ FunctionStubber.getInstance().registerInitializationStub( category.getFocusableTree() as Blockly.Toolbox, ); }, - 'init', + Blockly.CollapsibleToolboxCategory.prototype.init, Blockly.CollapsibleToolboxCategory.prototype, ); diff --git a/src/screenreader/stuboverrides/override_comment_icon.ts b/src/screenreader/stuboverrides/override_comment_icon.ts index b9fdf372..bb5aeb00 100644 --- a/src/screenreader/stuboverrides/override_comment_icon.ts +++ b/src/screenreader/stuboverrides/override_comment_icon.ts @@ -17,6 +17,6 @@ FunctionStubber.getInstance().registerInitializationStub( icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', ); }, - 'initView', + Blockly.icons.CommentIcon.prototype.initView, Blockly.icons.CommentIcon.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field.ts b/src/screenreader/stuboverrides/override_field.ts index b0fcb1cc..c07c1b3b 100644 --- a/src/screenreader/stuboverrides/override_field.ts +++ b/src/screenreader/stuboverrides/override_field.ts @@ -15,6 +15,7 @@ FunctionStubber.getInstance().registerInitializationStub( // @ts-expect-error Access to private property getTextElement. aria.setState(field.getTextElement(), aria.State.HIDDEN, true); }, - 'createTextElement_', + // @ts-expect-error Access to protected property createTextElement_. + Blockly.Field.prototype.createTextElement_, Blockly.Field.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field_checkbox.ts b/src/screenreader/stuboverrides/override_field_checkbox.ts index 9f2c7027..c8c68ed9 100644 --- a/src/screenreader/stuboverrides/override_field_checkbox.ts +++ b/src/screenreader/stuboverrides/override_field_checkbox.ts @@ -18,6 +18,6 @@ FunctionStubber.getInstance().registerInitializationStub( fieldCheckbox.name ? `Checkbox ${fieldCheckbox.name}` : 'Checkbox', ); }, - 'initView', + Blockly.FieldCheckbox.prototype.initView, Blockly.FieldCheckbox.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field_dropdown.ts b/src/screenreader/stuboverrides/override_field_dropdown.ts index 3bd197ca..d970622d 100644 --- a/src/screenreader/stuboverrides/override_field_dropdown.ts +++ b/src/screenreader/stuboverrides/override_field_dropdown.ts @@ -18,6 +18,6 @@ FunctionStubber.getInstance().registerInitializationStub( fieldDropdown.name ? `Item ${fieldDropdown.name}` : 'Item', ); }, - 'initView', + Blockly.FieldDropdown.prototype.initView, Blockly.FieldDropdown.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field_image.ts b/src/screenreader/stuboverrides/override_field_image.ts index ddace62d..31434108 100644 --- a/src/screenreader/stuboverrides/override_field_image.ts +++ b/src/screenreader/stuboverrides/override_field_image.ts @@ -18,6 +18,6 @@ FunctionStubber.getInstance().registerInitializationStub( fieldImage.name ? `Image ${fieldImage.name}` : 'Image', ); }, - 'initView', + Blockly.FieldImage.prototype.initView, Blockly.FieldImage.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field_input.ts b/src/screenreader/stuboverrides/override_field_input.ts index 6d20b2b6..5d3501a4 100644 --- a/src/screenreader/stuboverrides/override_field_input.ts +++ b/src/screenreader/stuboverrides/override_field_input.ts @@ -14,7 +14,7 @@ FunctionStubber.getInstance().registerInitializationStub( (fieldNumber) => { initializeFieldInput(fieldNumber); }, - 'init', + Blockly.FieldNumber.prototype.init, Blockly.FieldNumber.prototype, ); @@ -22,7 +22,7 @@ FunctionStubber.getInstance().registerInitializationStub( (fieldTextInput) => { initializeFieldInput(fieldTextInput); }, - 'init', + Blockly.FieldTextInput.prototype.init, Blockly.FieldTextInput.prototype, ); diff --git a/src/screenreader/stuboverrides/override_field_label.ts b/src/screenreader/stuboverrides/override_field_label.ts index 2063defe..751f4bf7 100644 --- a/src/screenreader/stuboverrides/override_field_label.ts +++ b/src/screenreader/stuboverrides/override_field_label.ts @@ -18,6 +18,6 @@ FunctionStubber.getInstance().registerInitializationStub( fieldLabel.getText(), ); }, - 'initView', + Blockly.FieldLabel.prototype.initView, Blockly.FieldLabel.prototype, ); diff --git a/src/screenreader/stuboverrides/override_flyout_button.ts b/src/screenreader/stuboverrides/override_flyout_button.ts index 4ae667a3..fcc2d148 100644 --- a/src/screenreader/stuboverrides/override_flyout_button.ts +++ b/src/screenreader/stuboverrides/override_flyout_button.ts @@ -14,6 +14,7 @@ FunctionStubber.getInstance().registerInitializationStub( aria.setRole(element, aria.Role.BUTTON); aria.setState(element, aria.State.LABEL, 'Button'); }, - 'updateTransform', + // @ts-expect-error Access to private property updateTransform. + Blockly.FlyoutButton.prototype.updateTransform, Blockly.FlyoutButton.prototype, ); diff --git a/src/screenreader/stuboverrides/override_icon.ts b/src/screenreader/stuboverrides/override_icon.ts index 6e83c23e..a8233544 100644 --- a/src/screenreader/stuboverrides/override_icon.ts +++ b/src/screenreader/stuboverrides/override_icon.ts @@ -14,6 +14,6 @@ FunctionStubber.getInstance().registerInitializationStub( aria.setRole(element, aria.Role.FIGURE); aria.setState(element, aria.State.LABEL, 'Icon'); }, - 'initView', + Blockly.icons.Icon.prototype.initView, Blockly.icons.Icon.prototype, ); diff --git a/src/screenreader/stuboverrides/override_mutator_icon.ts b/src/screenreader/stuboverrides/override_mutator_icon.ts index 5ef23e80..ac3171d2 100644 --- a/src/screenreader/stuboverrides/override_mutator_icon.ts +++ b/src/screenreader/stuboverrides/override_mutator_icon.ts @@ -17,6 +17,6 @@ FunctionStubber.getInstance().registerInitializationStub( icon.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', ); }, - 'initView', + Blockly.icons.MutatorIcon.prototype.initView, Blockly.icons.MutatorIcon.prototype, ); diff --git a/src/screenreader/stuboverrides/override_rendered_connection.ts b/src/screenreader/stuboverrides/override_rendered_connection.ts index 191cdec4..1a242bb1 100644 --- a/src/screenreader/stuboverrides/override_rendered_connection.ts +++ b/src/screenreader/stuboverrides/override_rendered_connection.ts @@ -18,6 +18,6 @@ FunctionStubber.getInstance().registerInitializationStub( aria.setRole(element, aria.Role.FIGURE); aria.setState(element, aria.State.LABEL, 'Open connection'); }, - 'highlight', + Blockly.RenderedConnection.prototype.highlight, Blockly.RenderedConnection.prototype, ); diff --git a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts index 5e5b0065..518308c8 100644 --- a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts +++ b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts @@ -14,6 +14,7 @@ FunctionStubber.getInstance().registerInitializationStub( aria.setRole(element, aria.Role.TEXTBOX); aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); }, - 'addModelUpdateBindings', + // @ts-expect-error Access to private property addModelUpdateBindings. + Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings, Blockly.comments.RenderedWorkspaceComment.prototype, ); diff --git a/src/screenreader/stuboverrides/override_toolbox.ts b/src/screenreader/stuboverrides/override_toolbox.ts index 1a401d17..8f91d07d 100644 --- a/src/screenreader/stuboverrides/override_toolbox.ts +++ b/src/screenreader/stuboverrides/override_toolbox.ts @@ -12,6 +12,6 @@ FunctionStubber.getInstance().registerInitializationStub( (toolbox) => { aria.setRole(toolbox.getFocusableElement(), aria.Role.TREE); }, - 'init', + Blockly.Toolbox.prototype.init, Blockly.Toolbox.prototype, ); diff --git a/src/screenreader/stuboverrides/override_toolbox_category.ts b/src/screenreader/stuboverrides/override_toolbox_category.ts index f0fab577..45602606 100644 --- a/src/screenreader/stuboverrides/override_toolbox_category.ts +++ b/src/screenreader/stuboverrides/override_toolbox_category.ts @@ -17,6 +17,6 @@ FunctionStubber.getInstance().registerInitializationStub( category.getFocusableTree() as Blockly.Toolbox, ); }, - 'init', + Blockly.ToolboxCategory.prototype.init, Blockly.ToolboxCategory.prototype, ); diff --git a/src/screenreader/stuboverrides/override_toolbox_separator.ts b/src/screenreader/stuboverrides/override_toolbox_separator.ts index bbcb2f80..2cd28849 100644 --- a/src/screenreader/stuboverrides/override_toolbox_separator.ts +++ b/src/screenreader/stuboverrides/override_toolbox_separator.ts @@ -16,6 +16,6 @@ FunctionStubber.getInstance().registerInitializationStub( separator.getFocusableTree() as Blockly.Toolbox, ); }, - 'init', + Blockly.ToolboxSeparator.prototype.init, Blockly.ToolboxSeparator.prototype, ); diff --git a/src/screenreader/stuboverrides/override_warning_icon.ts b/src/screenreader/stuboverrides/override_warning_icon.ts index d1b558a7..a0abdb44 100644 --- a/src/screenreader/stuboverrides/override_warning_icon.ts +++ b/src/screenreader/stuboverrides/override_warning_icon.ts @@ -17,6 +17,6 @@ FunctionStubber.getInstance().registerInitializationStub( icon.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', ); }, - 'initView', + Blockly.icons.WarningIcon.prototype.initView, Blockly.icons.WarningIcon.prototype, ); diff --git a/src/screenreader/stuboverrides/override_workspace_svg.ts b/src/screenreader/stuboverrides/override_workspace_svg.ts index c8508e02..231ac37d 100644 --- a/src/screenreader/stuboverrides/override_workspace_svg.ts +++ b/src/screenreader/stuboverrides/override_workspace_svg.ts @@ -25,6 +25,6 @@ FunctionStubber.getInstance().registerInitializationStub( } aria.setState(element, aria.State.LABEL, ariaLabel); }, - 'createDom', + Blockly.WorkspaceSvg.prototype.createDom, Blockly.WorkspaceSvg.prototype, ); From f34d85ba8be76297a171e5500206882986c84ffe Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 5 Aug 2025 21:53:12 +0000 Subject: [PATCH 13/17] chore: Small comment cleanups. --- src/screenreader/aria_monkey_patcher.js | 4 ++-- src/screenreader/block_svg_utilities.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/screenreader/aria_monkey_patcher.js b/src/screenreader/aria_monkey_patcher.js index 06ff9d51..4382d3a0 100644 --- a/src/screenreader/aria_monkey_patcher.js +++ b/src/screenreader/aria_monkey_patcher.js @@ -44,7 +44,7 @@ document.createElementNS = function (namepspaceURI, qualifiedName) { const oldElementSetAttribute = Element.prototype.setAttribute; // TODO: Replace these cases with property augmentation here so that all aria -// behavior is defined within this file. +// behavior is defined within the plugin. const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected']; Element.prototype.setAttribute = function (name, value) { @@ -56,7 +56,7 @@ Element.prototype.setAttribute = function (name, value) { if ( aria.isCurrentlyMutatingAriaProperty() || ariaAttributeAllowlist.includes(name) || - !name.startsWith('aria-') + (!name.startsWith('aria-') && name !== 'role') ) { oldElementSetAttribute.call(this, name, value); } diff --git a/src/screenreader/block_svg_utilities.ts b/src/screenreader/block_svg_utilities.ts index 15b5fafc..7ab98293 100644 --- a/src/screenreader/block_svg_utilities.ts +++ b/src/screenreader/block_svg_utilities.ts @@ -73,7 +73,7 @@ function computeLevelInWorkspace(block: Blockly.BlockSvg): number { export function recomputeAllWorkspaceAriaTrees( workspace: Blockly.WorkspaceSvg, ) { - // TODO: Do this efficiently (probably increementally). + // TODO: Do this efficiently (probably incrementally). workspace .getTopBlocks(false) .forEach((block) => recomputeAriaTreeItemDetailsRecursively(block)); From e95aa692692c47e953c92e4b4490198c5d0a3251 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 6 Aug 2025 00:15:59 +0000 Subject: [PATCH 14/17] fix: Ensure core changes work on this branch. Per https://github.com/google/blockly/pull/9280. --- src/actions/mover.ts | 2 -- src/keyboard_drag_strategy.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/src/actions/mover.ts b/src/actions/mover.ts index ebc9b1e4..fc20113e 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -244,11 +244,9 @@ export class Mover { this.patchDragger(info.dragger as dragging.Dragger, dragStrategy.moveType); // Save the position so we can put the cursor in a reasonable spot. - // @ts-expect-error Access to private property connectionCandidate. const target = dragStrategy.connectionCandidate?.neighbour; // Prevent the strategy connecting the block so we just delete one block. - // @ts-expect-error Access to private property connectionCandidate. dragStrategy.connectionCandidate = null; info.dragger.onDragEnd( diff --git a/src/keyboard_drag_strategy.ts b/src/keyboard_drag_strategy.ts index 09b3bcc2..45b67d11 100644 --- a/src/keyboard_drag_strategy.ts +++ b/src/keyboard_drag_strategy.ts @@ -50,7 +50,6 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { // to the top left of the workspace. // @ts-expect-error block and startLoc are private. this.block.moveDuringDrag(this.startLoc); - // @ts-expect-error connectionCandidate is private. this.connectionCandidate = this.createInitialCandidate(); this.forceShowPreview(); this.block.addIcon(new MoveIcon(this.block)); @@ -62,9 +61,7 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { super.drag(newLoc); // Handle the case when an unconstrained drag found a connection candidate. - // @ts-expect-error connectionCandidate is private. if (this.connectionCandidate) { - // @ts-expect-error connectionCandidate is private. const neighbour = (this.connectionCandidate as ConnectionCandidate) .neighbour; // The next constrained move will resume the search from the current @@ -253,7 +250,6 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { private forceShowPreview() { // @ts-expect-error connectionPreviewer is private const previewer = this.connectionPreviewer; - // @ts-expect-error connectionCandidate is private const candidate = this.connectionCandidate as ConnectionCandidate; if (!candidate || !previewer) return; const block = this.block; From 82dfb0c40f462a7834daf5bade052a632f845776 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 6 Aug 2025 00:21:54 +0000 Subject: [PATCH 15/17] chore: Remove mostly everything. The decision has been made to set up ARIA directly in core Blockly on a specific long-lived experimental branch. --- src/index.ts | 2 +- src/navigation_controller.ts | 7 - src/screenreader/aria.ts | 147 ------------- src/screenreader/aria_monkey_patcher.js | 67 ------ src/screenreader/block_svg_utilities.ts | 204 ------------------ src/screenreader/function_stubber_registry.ts | 173 --------------- .../stuboverrides/override_block_svg.ts | 89 -------- .../override_collapsible_toolbox_category.ts | 35 --- .../stuboverrides/override_comment_icon.ts | 22 -- .../stuboverrides/override_field.ts | 21 -- .../stuboverrides/override_field_checkbox.ts | 23 -- .../stuboverrides/override_field_dropdown.ts | 23 -- .../stuboverrides/override_field_image.ts | 23 -- .../stuboverrides/override_field_input.ts | 39 ---- .../stuboverrides/override_field_label.ts | 23 -- .../stuboverrides/override_flyout_button.ts | 20 -- .../stuboverrides/override_icon.ts | 19 -- .../stuboverrides/override_mutator_icon.ts | 22 -- .../override_rendered_connection.ts | 23 -- .../override_rendered_workspace_comment.ts | 20 -- .../stuboverrides/override_toolbox.ts | 17 -- .../override_toolbox_category.ts | 22 -- .../override_toolbox_separator.ts | 21 -- .../stuboverrides/override_warning_icon.ts | 22 -- .../stuboverrides/override_workspace_svg.ts | 30 --- src/screenreader/toolbox_utilities.ts | 36 ---- test/index.html | 8 - test/index.ts | 11 - test/webdriverio/index.ts | 10 - 29 files changed, 1 insertion(+), 1178 deletions(-) delete mode 100644 src/screenreader/aria.ts delete mode 100644 src/screenreader/aria_monkey_patcher.js delete mode 100644 src/screenreader/block_svg_utilities.ts delete mode 100644 src/screenreader/function_stubber_registry.ts delete mode 100644 src/screenreader/stuboverrides/override_block_svg.ts delete mode 100644 src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts delete mode 100644 src/screenreader/stuboverrides/override_comment_icon.ts delete mode 100644 src/screenreader/stuboverrides/override_field.ts delete mode 100644 src/screenreader/stuboverrides/override_field_checkbox.ts delete mode 100644 src/screenreader/stuboverrides/override_field_dropdown.ts delete mode 100644 src/screenreader/stuboverrides/override_field_image.ts delete mode 100644 src/screenreader/stuboverrides/override_field_input.ts delete mode 100644 src/screenreader/stuboverrides/override_field_label.ts delete mode 100644 src/screenreader/stuboverrides/override_flyout_button.ts delete mode 100644 src/screenreader/stuboverrides/override_icon.ts delete mode 100644 src/screenreader/stuboverrides/override_mutator_icon.ts delete mode 100644 src/screenreader/stuboverrides/override_rendered_connection.ts delete mode 100644 src/screenreader/stuboverrides/override_rendered_workspace_comment.ts delete mode 100644 src/screenreader/stuboverrides/override_toolbox.ts delete mode 100644 src/screenreader/stuboverrides/override_toolbox_category.ts delete mode 100644 src/screenreader/stuboverrides/override_toolbox_separator.ts delete mode 100644 src/screenreader/stuboverrides/override_warning_icon.ts delete mode 100644 src/screenreader/stuboverrides/override_workspace_svg.ts delete mode 100644 src/screenreader/toolbox_utilities.ts diff --git a/src/index.ts b/src/index.ts index 3199d5df..320d97ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -296,7 +296,7 @@ export class KeyboardNavigation { stroke: var(--blockly-active-node-color); stroke-width: var(--blockly-selection-width); } - + /* The workspace itself is the active node. */ .blocklyKeyboardNavigation .blocklyBubble.blocklyActiveFocus diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index 45424523..2e4be2d5 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -36,15 +36,8 @@ import {COMMIT_MOVE_SHORTCUT, Mover} from './actions/mover'; import {DuplicateAction} from './actions/duplicate'; import {StackNavigationAction} from './actions/stack_navigation'; -import './screenreader/aria_monkey_patcher'; -import {FunctionStubber} from './screenreader/function_stubber_registry'; - const KeyCodes = BlocklyUtils.KeyCodes; -// Note that prototype stubs must happen early in the page lifecycle in order to -// take effect before Blockly loading. -FunctionStubber.getInstance().stubPrototypes(); - /** * Class for registering shortcuts for keyboard navigation. */ diff --git a/src/screenreader/aria.ts b/src/screenreader/aria.ts deleted file mode 100644 index 975c4857..00000000 --- a/src/screenreader/aria.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -const ARIA_PREFIX = 'aria-'; -const ROLE_ATTRIBUTE = 'role'; - -/** Represents an ARIA role that an element may have. */ -export enum Role { - GROUP = 'group', - LISTBOX = 'listbox', - PRESENTATION = 'presentation', - TREE = 'tree', - TREEITEM = 'treeitem', - SEPARATOR = 'separator', - IMAGE = 'image', - FIGURE = 'figure', - BUTTON = 'button', - CHECKBOX = 'checkbox', - TEXTBOX = 'textbox', -} - -/** Represents ARIA-specific state that can be configured for an element. */ -export enum State { - LABEL = 'label', - LEVEL = 'level', - POSINSET = 'posinset', - SELECTED = 'selected', - SETSIZE = 'setsize', - LIVE = 'live', - HIDDEN = 'hidden', - ROLEDESCRIPTION = 'roledescription', - OWNS = 'owns', -} - -let isMutatingAriaProperty = false; - -/** - * Updates the specific role for the specified element. - * - * @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) { - isMutatingAriaProperty = true; - if (roleName) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); - } else element.removeAttribute(ROLE_ATTRIBUTE); - isMutatingAriaProperty = false; -} - -/** - * 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 The element from which to retrieve its ARIA role. - * @returns The ARIA role of the element, or null if undefined or unknown. - */ -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 specified ARIA state by its name and value for the specified - * element. - * - * 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, - stateName: State, - value: string | boolean | number | string[], -) { - isMutatingAriaProperty = true; - if (Array.isArray(value)) { - value = value.join(' '); - } - const attrStateName = ARIA_PREFIX + stateName; - element.setAttribute(attrStateName, `${value}`); - isMutatingAriaProperty = false; -} - -/** - * 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); -} - -/** - * Softly 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 should not interrupt - * existing announcements (that is, this is what's considered a polite - * announcement). - * - * 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 only be used 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 politely 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; -} - -/** - * Determines whether an ARIA property is in the process of being changed. - * - * @returns Returns whether an ARIA property is changing for any element, - * specifically via setRole() or stateState(). - */ -export function isCurrentlyMutatingAriaProperty(): boolean { - return isMutatingAriaProperty; -} diff --git a/src/screenreader/aria_monkey_patcher.js b/src/screenreader/aria_monkey_patcher.js deleted file mode 100644 index 4382d3a0..00000000 --- a/src/screenreader/aria_monkey_patcher.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Overrides a bunch of methods throughout core Blockly in order - * to augment Blockly components with ARIA support. - */ - -import * as aria from './aria'; -import './stuboverrides/override_block_svg'; -import './stuboverrides/override_collapsible_toolbox_category'; -import './stuboverrides/override_comment_icon'; -import './stuboverrides/override_field_checkbox'; -import './stuboverrides/override_field_dropdown'; -import './stuboverrides/override_field_image'; -import './stuboverrides/override_field_input'; -import './stuboverrides/override_field_label'; -import './stuboverrides/override_field'; -import './stuboverrides/override_flyout_button'; -import './stuboverrides/override_icon'; -import './stuboverrides/override_mutator_icon'; -import './stuboverrides/override_rendered_connection'; -import './stuboverrides/override_rendered_workspace_comment'; -import './stuboverrides/override_toolbox_category'; -import './stuboverrides/override_toolbox_separator'; -import './stuboverrides/override_toolbox'; -import './stuboverrides/override_warning_icon'; -import './stuboverrides/override_workspace_svg'; - -const oldCreateElementNS = document.createElementNS; - -document.createElementNS = function (namepspaceURI, qualifiedName) { - const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName); - // Top-level SVG elements and groups are presentation by default. They will be - // specified more specifically elsewhere if they need to be readable. - if (qualifiedName === 'svg' || qualifiedName === 'g') { - aria.setRole(element, aria.Role.PRESENTATION); - } - return element; -}; - -const oldElementSetAttribute = Element.prototype.setAttribute; -// TODO: Replace these cases with property augmentation here so that all aria -// behavior is defined within the plugin. -const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected']; - -Element.prototype.setAttribute = function (name, value) { - // This is a hacky way to disable all aria changes in core Blockly since it's - // easier to just undefine everything globally and then conditionally reenable - // things with the correct definitions. - // TODO: Add an exemption for role here once all roles are properly defined - // within this file (see failing tests when role changes are ignored here). - if ( - aria.isCurrentlyMutatingAriaProperty() || - ariaAttributeAllowlist.includes(name) || - (!name.startsWith('aria-') && name !== 'role') - ) { - oldElementSetAttribute.call(this, name, value); - } -}; - -// TODO: Figure out how to patch CommentEditor. It doesn't seem to have any methods really to override, so it may actually require patching at the dom utility layer, or higher up. -// TODO: Ditto for CommentBarButton and its children. -// TODO: Ditto for Bubble and its children. diff --git a/src/screenreader/block_svg_utilities.ts b/src/screenreader/block_svg_utilities.ts deleted file mode 100644 index 7ab98293..00000000 --- a/src/screenreader/block_svg_utilities.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as Blockly from 'blockly/core'; -import * as aria from './aria'; - -/** - * Computes the human-readable ARIA label for the specified block. - * - * @param block The block whose label should be computed. - * @returns A human-readable ARIA label/representation for the block. - */ -export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { - // Guess the block's aria label based on its field labels. - if (block.isShadow()) { - // TODO: Shadows may have more than one field. - // Shadow blocks are best represented directly by their field since they - // effectively operate like a field does for keyboard navigation purposes. - const field = Array.from(block.getFields())[0]; - return ( - aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?' - ); - } - - const fieldLabels = []; - for (const field of block.getFields()) { - if (field instanceof Blockly.FieldLabel) { - fieldLabels.push(field.getText()); - } - } - return fieldLabels.join(' '); -} - -function collectSiblingBlocks( - block: Blockly.BlockSvg, - surroundParent: Blockly.BlockSvg | null, -): Blockly.BlockSvg[] { - // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The - // returned list needs to be relatively stable for consistency block indexes - // read out to users via screen readers. - if (surroundParent) { - // Start from the first sibling and iterate in navigation order. - const firstSibling: Blockly.BlockSvg = surroundParent.getChildren(false)[0]; - const siblings: Blockly.BlockSvg[] = [firstSibling]; - let nextSibling: Blockly.BlockSvg | null = firstSibling; - while ((nextSibling = nextSibling.getNextBlock())) { - siblings.push(nextSibling); - } - return siblings; - } else { - // For top-level blocks, simply return those from the workspace. - return block.workspace.getTopBlocks(false); - } -} - -function computeLevelInWorkspace(block: Blockly.BlockSvg): number { - const surroundParent = block.getSurroundParent(); - return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0; -} - -/** - * Recomputes all BlockSvg ARIA tree structures in the workspace. - * - * This is a fairly expensive operation and should ideally only be performed - * when a block structure or relationship change has been made. - * - * @param workspace The workspace whose top-level blocks may need a tree - * structure recomputation. - */ -export function recomputeAllWorkspaceAriaTrees( - workspace: Blockly.WorkspaceSvg, -) { - // TODO: Do this efficiently (probably incrementally). - workspace - .getTopBlocks(false) - .forEach((block) => recomputeAriaTreeItemDetailsRecursively(block)); -} - -function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) { - const elem = block.getFocusableElement(); - const connection = getCurrentConnectionCandidate(block); - let childPosition: number; - let parentsChildCount: number; - let hierarchyDepth: number; - if (connection) { - // If the block is being inserted into a new location, the position is hypothetical. - // TODO: Figure out how to deal with output connections. - let surroundParent: Blockly.BlockSvg | null; - let siblingBlocks: Blockly.BlockSvg[]; - if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { - surroundParent = connection.sourceBlock_; - siblingBlocks = collectSiblingBlocks(block, surroundParent); - // The block is being added as a child since it's input. - // TODO: Figure out how to compute the correct position. - childPosition = 1; - } else { - surroundParent = connection.sourceBlock_.getSurroundParent(); - siblingBlocks = collectSiblingBlocks(block, surroundParent); - // The block is being added after the connected block. - childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; - } - parentsChildCount = siblingBlocks.length + 1; - hierarchyDepth = surroundParent - ? computeLevelInWorkspace(surroundParent) + 1 - : 1; - } else { - const surroundParent = block.getSurroundParent(); - const siblingBlocks = collectSiblingBlocks(block, surroundParent); - childPosition = siblingBlocks.indexOf(block) + 1; - parentsChildCount = siblingBlocks.length; - hierarchyDepth = computeLevelInWorkspace(block) + 1; - } - aria.setState(elem, aria.State.POSINSET, childPosition); - aria.setState(elem, aria.State.SETSIZE, parentsChildCount); - aria.setState(elem, aria.State.LEVEL, hierarchyDepth); - block - .getChildren(false) - .forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); -} - -/** - * 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 block The block whose dynamic state should maybe be announced. - * @param isMoving Whether the specified block is currently being moved. - * @param isCanceled Whether the previous movement operation has been canceled. - * @param newLoc The new location the block is moving to (if unconstrained). - */ -export function announceDynamicAriaStateForBlock( - block: Blockly.BlockSvg, - isMoving: boolean, - isCanceled: boolean, - newLoc?: Blockly.utils.Coordinate, -) { - const connection = getCurrentConnectionCandidate(block); - if (isCanceled) { - aria.announceDynamicAriaState('Canceled movement'); - return; - } - if (!isMoving) return; - if (connection) { - // TODO: Figure out general detachment. - // TODO: Figure out how to deal with output connections. - const surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_; - const announcementContext = []; - announcementContext.push('Moving'); // TODO: Specialize for inserting? - // NB: Old code here doesn't seem to handle parents correctly. - if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { - announcementContext.push('to', 'input'); - } else { - announcementContext.push('to', 'child'); - } - if (surroundParent) { - announcementContext.push('of', computeBlockAriaLabel(surroundParent)); - } - - // 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. - aria.announceDynamicAriaState( - `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, - ); - } -} - -interface ConnectionCandidateHolder { - currentConnectionCandidate: Blockly.RenderedConnection | null; -} - -function getCurrentConnectionCandidate( - block: Blockly.BlockSvg, -): Blockly.RenderedConnection | null { - const connectionHolder = block as unknown as ConnectionCandidateHolder; - return connectionHolder.currentConnectionCandidate; -} - -/** - * Updates the current connection candidate for the specified block (that is, - * the connection the block is being connected to). - * - * This corresponds to a temporary property used when determining specifics of - * a block's location when being moved. - * - * @param block The block which may have a new connection candidate. - * @param connection The latest connection candidate for the block, or null if - * none. - */ -export function setCurrentConnectionCandidate( - block: Blockly.BlockSvg, - connection: Blockly.RenderedConnection | null, -) { - const connectionHolder = block as unknown as ConnectionCandidateHolder; - connectionHolder.currentConnectionCandidate = connection; -} diff --git a/src/screenreader/function_stubber_registry.ts b/src/screenreader/function_stubber_registry.ts deleted file mode 100644 index 466feecb..00000000 --- a/src/screenreader/function_stubber_registry.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * A function callback used to run after an overridden stub method using - * FunctionStubber. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type StubCallback = (instance: T, ...args: any) => void; - -/** The type representation of a generic function that can be stubbed. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type GenericFunction = (...args: any) => any; - -class Registration { - private oldMethod: GenericFunction | null = null; - - constructor( - readonly callback: StubCallback, - readonly methodToOverride: GenericFunction, - readonly classPrototype: T, - readonly ensureOneCall: boolean, - ) {} - - stubPrototype(): void { - if (this.oldMethod) { - throw new Error( - `Function is already stubbed: ${this.methodToOverride.name}.`, - ); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const genericPrototype = this.classPrototype as any; - this.oldMethod = this.methodToOverride; - // eslint-disable-next-line @typescript-eslint/no-this-alias - const registration = this; - const methodNameToOverride = this.methodToOverride.name; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - genericPrototype[methodNameToOverride] = function (...args: any): any { - let stubsCalled = this._internalStubsCalled as - | {[key: string]: boolean} - | undefined; - if (!stubsCalled) { - stubsCalled = {}; - this._internalStubsCalled = stubsCalled; - } - - const result = registration.methodToOverride.call(this, ...args); - if ( - !registration.ensureOneCall || - !stubsCalled[registration.methodToOverride.name] - ) { - registration.callback(this as unknown as T, ...args); - stubsCalled[registration.methodToOverride.name] = true; - } - return result; - }; - } -} - -/** - * Utility for augmenting a class's functionality by monkey-patching a - * function's prototype in order to call a custom function. - * - * Note that all custom functions are always run after the original function - * runs. This order cannot be configured, nor can the original function be - * disabled. - * - * There are two types of overrides possible: initialization via - * registerInitializationStub() and regular class methods via - * registerMethodStub(). - * - * Instances of this class should retrieved using getInstance(). - * - * IMPORTANT: In order for stubbing to work correctly, see the caveats of - * stubPrototypes(). - */ -export class FunctionStubber { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private registrations: Array> = []; - private isFinalized = false; - - /** - * Registers a new initialization stub. - * - * Initialization stub callbacks are only invoked once per instance of a given - * object, even if that function is called multiple times. This allows for - * methods called in a class's constructor to be used as a proxy for the - * constructor itself. - * - * This will throw an error if called after stubPrototypes() has been called. - * - * @param callback The function to run when the stubbed method executes for - * the first time. - * @param methodToOverride The method within the prototype to override. - * @param classPrototype The prototype of the class being stubbed. - */ - registerInitializationStub( - callback: StubCallback, - methodToOverride: GenericFunction, - classPrototype: T, - ) { - if (this.isFinalized) { - throw new Error( - 'Cannot register a stub after initialization has been completed.', - ); - } - const registration = new Registration( - callback, - methodToOverride, - classPrototype, - true, - ); - this.registrations.push(registration); - } - - /** - * Registers a new method stub. - * - * Method stub callbacks are invoked every time the overridden method is - * invoked. - * - * This will throw an error if called after stubPrototypes() has been called. - * - * @param callback The function to run when the stubbed method executes. - * @param methodToOverride The method within the prototype to override. - * @param classPrototype The prototype of the class being stubbed. - */ - registerMethodStub( - callback: StubCallback, - methodToOverride: GenericFunction, - classPrototype: T, - ) { - if (this.isFinalized) { - throw new Error( - 'Cannot register a stub after initialization has been completed.', - ); - } - const registration = new Registration( - callback, - methodToOverride, - classPrototype, - false, - ); - this.registrations.push(registration); - } - - /** - * Performs the actual monkey-patching to enable the custom registered - * callbacks from registerInitializationStub() and registerMethodStub() to - * work correctly. - * - * IMPORTANT: This must be called after all registration is completed, and - * before any of the stubbed classes are actually used. This cannot be undone - * (that is, there is no deregistration). - */ - stubPrototypes() { - this.isFinalized = true; - this.registrations.forEach((registration) => registration.stubPrototype()); - } - - private static instance: FunctionStubber | null = null; - - /** Returns the page-global instance of this FunctionStubber. */ - static getInstance(): FunctionStubber { - if (!FunctionStubber.instance) { - FunctionStubber.instance = new FunctionStubber(); - } - return FunctionStubber.instance; - } -} diff --git a/src/screenreader/stuboverrides/override_block_svg.ts b/src/screenreader/stuboverrides/override_block_svg.ts deleted file mode 100644 index 68ec0bda..00000000 --- a/src/screenreader/stuboverrides/override_block_svg.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; -import * as blockSvgUtils from '../block_svg_utilities'; - -FunctionStubber.getInstance().registerInitializationStub( - (block) => { - const svgPath = block.getFocusableElement(); - aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); - aria.setRole(svgPath, aria.Role.TREEITEM); - aria.setState( - svgPath, - aria.State.LABEL, - blockSvgUtils.computeBlockAriaLabel(block), - ); - svgPath.tabIndex = -1; - blockSvgUtils.setCurrentConnectionCandidate(block, null); - }, - // @ts-expect-error Access to protected property doInit_. - Blockly.BlockSvg.prototype.doInit_, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => blockSvgUtils.recomputeAllWorkspaceAriaTrees(block.workspace), - Blockly.BlockSvg.prototype.setParent, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => { - // @ts-expect-error Access to private property dragStrategy. - const candidate = block.dragStrategy.connectionCandidate?.neighbour ?? null; - blockSvgUtils.setCurrentConnectionCandidate(block, candidate); - blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false); - }, - Blockly.BlockSvg.prototype.startDrag, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block, newLoc: Blockly.utils.Coordinate) => { - // @ts-expect-error Access to private property dragStrategy. - const candidate = block.dragStrategy.connectionCandidate?.neighbour ?? null; - blockSvgUtils.setCurrentConnectionCandidate(block, candidate); - blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc); - }, - Blockly.BlockSvg.prototype.drag, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => { - blockSvgUtils.setCurrentConnectionCandidate(block, null); - blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false); - }, - Blockly.BlockSvg.prototype.endDrag, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => { - blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true); - }, - Blockly.BlockSvg.prototype.revertDrag, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => { - aria.setState(block.getFocusableElement(), aria.State.SELECTED, true); - }, - Blockly.BlockSvg.prototype.onNodeFocus, - Blockly.BlockSvg.prototype, -); - -FunctionStubber.getInstance().registerMethodStub( - (block) => { - aria.setState(block.getFocusableElement(), aria.State.SELECTED, false); - }, - Blockly.BlockSvg.prototype.onNodeBlur, - Blockly.BlockSvg.prototype, -); diff --git a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts b/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts deleted file mode 100644 index b7a06fd9..00000000 --- a/src/screenreader/stuboverrides/override_collapsible_toolbox_category.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; -import * as toolboxUtils from '../toolbox_utilities'; - -FunctionStubber.getInstance().registerInitializationStub( - (category) => { - const element = category.getFocusableElement(); - aria.setRole(element, aria.Role.GROUP); - - // Ensure this group has properly set children. - const selectableChildren = - category.getChildToolboxItems().filter((item) => item.isSelectable()) ?? - null; - const focusableChildIds = selectableChildren.map( - (selectable) => selectable.getFocusableElement().id, - ); - aria.setState( - element, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - toolboxUtils.recomputeAriaOwnersInToolbox( - category.getFocusableTree() as Blockly.Toolbox, - ); - }, - Blockly.CollapsibleToolboxCategory.prototype.init, - Blockly.CollapsibleToolboxCategory.prototype, -); diff --git a/src/screenreader/stuboverrides/override_comment_icon.ts b/src/screenreader/stuboverrides/override_comment_icon.ts deleted file mode 100644 index bb5aeb00..00000000 --- a/src/screenreader/stuboverrides/override_comment_icon.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', - ); - }, - Blockly.icons.CommentIcon.prototype.initView, - Blockly.icons.CommentIcon.prototype, -); diff --git a/src/screenreader/stuboverrides/override_field.ts b/src/screenreader/stuboverrides/override_field.ts deleted file mode 100644 index c07c1b3b..00000000 --- a/src/screenreader/stuboverrides/override_field.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (field) => { - // The text itself is presentation since it's represented through the - // block's ARIA label. - // @ts-expect-error Access to private property getTextElement. - aria.setState(field.getTextElement(), aria.State.HIDDEN, true); - }, - // @ts-expect-error Access to protected property createTextElement_. - Blockly.Field.prototype.createTextElement_, - Blockly.Field.prototype, -); diff --git a/src/screenreader/stuboverrides/override_field_checkbox.ts b/src/screenreader/stuboverrides/override_field_checkbox.ts deleted file mode 100644 index c8c68ed9..00000000 --- a/src/screenreader/stuboverrides/override_field_checkbox.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (fieldCheckbox) => { - const element = fieldCheckbox.getFocusableElement(); - aria.setRole(element, aria.Role.CHECKBOX); - aria.setState( - element, - aria.State.LABEL, - fieldCheckbox.name ? `Checkbox ${fieldCheckbox.name}` : 'Checkbox', - ); - }, - Blockly.FieldCheckbox.prototype.initView, - Blockly.FieldCheckbox.prototype, -); diff --git a/src/screenreader/stuboverrides/override_field_dropdown.ts b/src/screenreader/stuboverrides/override_field_dropdown.ts deleted file mode 100644 index d970622d..00000000 --- a/src/screenreader/stuboverrides/override_field_dropdown.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (fieldDropdown) => { - const element = fieldDropdown.getFocusableElement(); - aria.setRole(element, aria.Role.LISTBOX); - aria.setState( - element, - aria.State.LABEL, - fieldDropdown.name ? `Item ${fieldDropdown.name}` : 'Item', - ); - }, - Blockly.FieldDropdown.prototype.initView, - Blockly.FieldDropdown.prototype, -); diff --git a/src/screenreader/stuboverrides/override_field_image.ts b/src/screenreader/stuboverrides/override_field_image.ts deleted file mode 100644 index 31434108..00000000 --- a/src/screenreader/stuboverrides/override_field_image.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (fieldImage) => { - const element = fieldImage.getFocusableElement(); - aria.setRole(element, aria.Role.IMAGE); - aria.setState( - element, - aria.State.LABEL, - fieldImage.name ? `Image ${fieldImage.name}` : 'Image', - ); - }, - Blockly.FieldImage.prototype.initView, - Blockly.FieldImage.prototype, -); diff --git a/src/screenreader/stuboverrides/override_field_input.ts b/src/screenreader/stuboverrides/override_field_input.ts deleted file mode 100644 index 5d3501a4..00000000 --- a/src/screenreader/stuboverrides/override_field_input.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -// Note: These can be consolidated to FieldInput, but that's not exported so it -// has to be overwritten on a per-field basis. -FunctionStubber.getInstance().registerInitializationStub( - (fieldNumber) => { - initializeFieldInput(fieldNumber); - }, - Blockly.FieldNumber.prototype.init, - Blockly.FieldNumber.prototype, -); - -FunctionStubber.getInstance().registerInitializationStub( - (fieldTextInput) => { - initializeFieldInput(fieldTextInput); - }, - Blockly.FieldTextInput.prototype.init, - Blockly.FieldTextInput.prototype, -); - -function initializeFieldInput( - fieldInput: Blockly.FieldNumber | Blockly.FieldTextInput, -): void { - const element = fieldInput.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState( - element, - aria.State.LABEL, - fieldInput.name ? `Text ${fieldInput.name}` : 'Text', - ); -} diff --git a/src/screenreader/stuboverrides/override_field_label.ts b/src/screenreader/stuboverrides/override_field_label.ts deleted file mode 100644 index 751f4bf7..00000000 --- a/src/screenreader/stuboverrides/override_field_label.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (fieldLabel) => { - // There's no additional semantic meaning needed for a label; the aria-label - // should be sufficient for context. - aria.setState( - fieldLabel.getFocusableElement(), - aria.State.LABEL, - fieldLabel.getText(), - ); - }, - Blockly.FieldLabel.prototype.initView, - Blockly.FieldLabel.prototype, -); diff --git a/src/screenreader/stuboverrides/override_flyout_button.ts b/src/screenreader/stuboverrides/override_flyout_button.ts deleted file mode 100644 index fcc2d148..00000000 --- a/src/screenreader/stuboverrides/override_flyout_button.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (flyoutButton) => { - const element = flyoutButton.getFocusableElement(); - aria.setRole(element, aria.Role.BUTTON); - aria.setState(element, aria.State.LABEL, 'Button'); - }, - // @ts-expect-error Access to private property updateTransform. - Blockly.FlyoutButton.prototype.updateTransform, - Blockly.FlyoutButton.prototype, -); diff --git a/src/screenreader/stuboverrides/override_icon.ts b/src/screenreader/stuboverrides/override_icon.ts deleted file mode 100644 index a8233544..00000000 --- a/src/screenreader/stuboverrides/override_icon.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (icon) => { - const element = icon.getFocusableElement(); - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Icon'); - }, - Blockly.icons.Icon.prototype.initView, - Blockly.icons.Icon.prototype, -); diff --git a/src/screenreader/stuboverrides/override_mutator_icon.ts b/src/screenreader/stuboverrides/override_mutator_icon.ts deleted file mode 100644 index ac3171d2..00000000 --- a/src/screenreader/stuboverrides/override_mutator_icon.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', - ); - }, - Blockly.icons.MutatorIcon.prototype.initView, - Blockly.icons.MutatorIcon.prototype, -); diff --git a/src/screenreader/stuboverrides/override_rendered_connection.ts b/src/screenreader/stuboverrides/override_rendered_connection.ts deleted file mode 100644 index 1a242bb1..00000000 --- a/src/screenreader/stuboverrides/override_rendered_connection.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (connection) => { - // This is a later initialization than most components but it's likely - // adequate since the creation of RenderedConnection's focusable element is - // part of the block rendering lifecycle (so the class itself isn't even aware - // when its element exists). - const element = connection.getFocusableElement(); - aria.setRole(element, aria.Role.FIGURE); - aria.setState(element, aria.State.LABEL, 'Open connection'); - }, - Blockly.RenderedConnection.prototype.highlight, - Blockly.RenderedConnection.prototype, -); diff --git a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts b/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts deleted file mode 100644 index 518308c8..00000000 --- a/src/screenreader/stuboverrides/override_rendered_workspace_comment.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (comment) => { - const element = comment.getFocusableElement(); - aria.setRole(element, aria.Role.TEXTBOX); - aria.setState(element, aria.State.LABEL, 'DoNotOverride?'); - }, - // @ts-expect-error Access to private property addModelUpdateBindings. - Blockly.comments.RenderedWorkspaceComment.prototype.addModelUpdateBindings, - Blockly.comments.RenderedWorkspaceComment.prototype, -); diff --git a/src/screenreader/stuboverrides/override_toolbox.ts b/src/screenreader/stuboverrides/override_toolbox.ts deleted file mode 100644 index 8f91d07d..00000000 --- a/src/screenreader/stuboverrides/override_toolbox.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (toolbox) => { - aria.setRole(toolbox.getFocusableElement(), aria.Role.TREE); - }, - Blockly.Toolbox.prototype.init, - Blockly.Toolbox.prototype, -); diff --git a/src/screenreader/stuboverrides/override_toolbox_category.ts b/src/screenreader/stuboverrides/override_toolbox_category.ts deleted file mode 100644 index 45602606..00000000 --- a/src/screenreader/stuboverrides/override_toolbox_category.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; -import * as toolboxUtils from '../toolbox_utilities'; - -// TODO: Reimplement selected for items and expanded for categories, and levels. -FunctionStubber.getInstance().registerInitializationStub( - (category) => { - aria.setRole(category.getFocusableElement(), aria.Role.TREEITEM); - toolboxUtils.recomputeAriaOwnersInToolbox( - category.getFocusableTree() as Blockly.Toolbox, - ); - }, - Blockly.ToolboxCategory.prototype.init, - Blockly.ToolboxCategory.prototype, -); diff --git a/src/screenreader/stuboverrides/override_toolbox_separator.ts b/src/screenreader/stuboverrides/override_toolbox_separator.ts deleted file mode 100644 index 2cd28849..00000000 --- a/src/screenreader/stuboverrides/override_toolbox_separator.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; -import * as toolboxUtils from '../toolbox_utilities'; - -FunctionStubber.getInstance().registerInitializationStub( - (separator) => { - aria.setRole(separator.getFocusableElement(), aria.Role.SEPARATOR); - toolboxUtils.recomputeAriaOwnersInToolbox( - separator.getFocusableTree() as Blockly.Toolbox, - ); - }, - Blockly.ToolboxSeparator.prototype.init, - Blockly.ToolboxSeparator.prototype, -); diff --git a/src/screenreader/stuboverrides/override_warning_icon.ts b/src/screenreader/stuboverrides/override_warning_icon.ts deleted file mode 100644 index a0abdb44..00000000 --- a/src/screenreader/stuboverrides/override_warning_icon.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (icon) => { - const element = icon.getFocusableElement(); - aria.setState( - element, - aria.State.LABEL, - icon.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', - ); - }, - Blockly.icons.WarningIcon.prototype.initView, - Blockly.icons.WarningIcon.prototype, -); diff --git a/src/screenreader/stuboverrides/override_workspace_svg.ts b/src/screenreader/stuboverrides/override_workspace_svg.ts deleted file mode 100644 index 231ac37d..00000000 --- a/src/screenreader/stuboverrides/override_workspace_svg.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {FunctionStubber} from '../function_stubber_registry'; -import * as Blockly from 'blockly/core'; -import * as aria from '../aria'; - -FunctionStubber.getInstance().registerInitializationStub( - (workspace) => { - const element = workspace.getFocusableElement(); - aria.setRole(element, aria.Role.TREE); - let ariaLabel = null; - // @ts-expect-error Access to private property injectionDiv. - if (workspace.injectionDiv) { - ariaLabel = Blockly.Msg['WORKSPACE_ARIA_LABEL']; - } else if (workspace.isFlyout) { - ariaLabel = 'Flyout'; - } else if (workspace.isMutator) { - ariaLabel = 'Mutator'; - } else { - throw new Error('Cannot determine ARIA label for workspace.'); - } - aria.setState(element, aria.State.LABEL, ariaLabel); - }, - Blockly.WorkspaceSvg.prototype.createDom, - Blockly.WorkspaceSvg.prototype, -); diff --git a/src/screenreader/toolbox_utilities.ts b/src/screenreader/toolbox_utilities.ts deleted file mode 100644 index ac9c3515..00000000 --- a/src/screenreader/toolbox_utilities.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as Blockly from 'blockly/core'; -import * as aria from './aria'; - -/** - * Recomputes ARIA tree ownership relationships for all of the specified - * Toolbox's categories and items. - * - * This should only be done when the Toolbox's contents have changed. - * - * @param toolbox The toolbox whose ARIA tree should be recomputed. - */ -export function recomputeAriaOwnersInToolbox(toolbox: Blockly.Toolbox) { - const focusable = toolbox.getFocusableElement(); - const selectableChildren = - toolbox.getToolboxItems().filter((item) => item.isSelectable()) ?? null; - const focusableChildElems = selectableChildren.map((selectable) => - selectable.getFocusableElement(), - ); - const focusableChildIds = focusableChildElems.map((elem) => elem.id); - aria.setState( - focusable, - aria.State.OWNS, - [...new Set(focusableChildIds)].join(' '), - ); - // Ensure children have the correct position set. - // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. - focusableChildElems.forEach((elem, index) => - aria.setState(elem, aria.State.POSINSET, index + 1), - ); -} diff --git a/test/index.html b/test/index.html index 3411f4a6..d4cb4378 100644 --- a/test/index.html +++ b/test/index.html @@ -85,14 +85,6 @@ thead { font-weight: bold; } - - #blocklyAriaAnnounce { - position: absolute; - left: -9999px; - width: 1px; - height: px; - overflow: hidden; - } diff --git a/test/index.ts b/test/index.ts index 5d60b447..4aa282a8 100644 --- a/test/index.ts +++ b/test/index.ts @@ -24,7 +24,6 @@ import {javascriptGenerator} from 'blockly/javascript'; // @ts-expect-error No types in js file import {load} from './loadTestBlocks'; import {runCode, registerRunCodeShortcut} from './runCode'; -import * as aria from '../src/screenreader/aria'; (window as unknown as {Blockly: typeof Blockly}).Blockly = Blockly; @@ -99,16 +98,6 @@ function createWorkspace(): Blockly.WorkspaceSvg { registerNavigationDeferringToolbox(); const workspace = Blockly.inject(blocklyDiv, injectOptions); - const injectionDiv = document.querySelector('.injectionDiv'); - if (!injectionDiv) { - throw new Error('Expected injection div to exist after injection.'); - } - // See: https://stackoverflow.com/a/48590836 for a reference. - const ariaAnnouncementSpan = document.createElement('span'); - ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; - aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); - injectionDiv.appendChild(ariaAnnouncementSpan); - Blockly.ContextMenuItems.registerCommentOptions(); new KeyboardNavigation(workspace); registerRunCodeShortcut(); diff --git a/test/webdriverio/index.ts b/test/webdriverio/index.ts index 7a328f8a..f6b67963 100644 --- a/test/webdriverio/index.ts +++ b/test/webdriverio/index.ts @@ -9,7 +9,6 @@ import * as Blockly from 'blockly'; import 'blockly/blocks'; import {installAllBlocks as installColourBlocks} from '@blockly/field-colour'; import {KeyboardNavigation} from '../../src/index'; -import * as aria from '../../src/screenreader/aria'; import {registerFlyoutCursor} from '../../src/flyout_cursor'; import {registerNavigationDeferringToolbox} from '../../src/navigation_deferring_toolbox'; // @ts-expect-error No types in js file @@ -87,15 +86,6 @@ function createWorkspace(): Blockly.WorkspaceSvg { registerNavigationDeferringToolbox(); const workspace = Blockly.inject(blocklyDiv, injectOptions); - const injectionDiv = document.querySelector('.injectionDiv'); - if (!injectionDiv) { - throw new Error('Expected injection div to exist after injection.'); - } - const ariaAnnouncementSpan = document.createElement('span'); - ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; - aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); - injectionDiv.appendChild(ariaAnnouncementSpan); - Blockly.ContextMenuItems.registerCommentOptions(); new KeyboardNavigation(workspace); From 381c1f7ca1fad501353b53b6d29fb6c67a74ad62 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 6 Aug 2025 22:40:27 +0000 Subject: [PATCH 16/17] fix: Link against correct core branch for CI. --- .github/workflows/test.yml | 39 ++++++-------------------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6419b501..f52a5bff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,14 +7,14 @@ on: pull_request: push: branches: - - main + - add-screen-reader-support-experimental permissions: contents: read jobs: - webdriverio_tests_tip_of_tree_v12: - name: WebdriverIO tests (against tip-of-tree core develop) + webdriverio_tests: + name: WebdriverIO tests (against add-screen-reader-support-experimental core develop) timeout-minutes: 10 runs-on: ${{ matrix.os }} @@ -29,11 +29,11 @@ jobs: with: path: main - - name: Checkout core Blockly + - name: Checkout experimentation Blockly uses: actions/checkout@v4 with: repository: 'google/blockly' - ref: 'develop' + ref: 'add-screen-reader-support-experimental' path: core-blockly - name: Use Node.js 20.x @@ -50,7 +50,7 @@ jobs: npm install cd .. - - name: Link latest Blockly develop + - name: Link latest Blockly add-screen-reader-support-experimental run: | cd core-blockly npm run package @@ -64,30 +64,3 @@ jobs: run: | cd main npm run test - - webdriverio_tests: - name: WebdriverIO tests (against pinned v12) - # Don't run pinned version checks for PRs. - if: ${{ !github.base_ref }} - timeout-minutes: 10 - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - - steps: - - name: Checkout experimentation plugin - uses: actions/checkout@v4 - - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - - - name: NPM install - run: npm install - - - name: Run tests - run: npm run test From a3ac5f61ccf994f740d51bb6f18e903ded2e2230 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 6 Aug 2025 22:42:05 +0000 Subject: [PATCH 17/17] fix: Use correct core branch for CI builds, too. --- .github/workflows/build.yml | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41f96b40..8391cf1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,14 +6,14 @@ on: pull_request: push: branches: - - main + - add-screen-reader-support-experimental permissions: contents: read jobs: build_tip_of_tree_v12: - name: Build test (against tip-of-tree core develop) + name: Build test (against add-screen-reader-support-experimental core develop) runs-on: ubuntu-latest steps: - name: Checkout experimentation plugin @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 with: repository: 'google/blockly' - ref: 'develop' + ref: 'add-screen-reader-support-experimental' path: core-blockly - name: Use Node.js 20.x @@ -42,7 +42,7 @@ jobs: npm install cd .. - - name: Link latest Blockly develop + - name: Link latest Blockly add-screen-reader-support-experimental run: | cd core-blockly npm run package @@ -57,26 +57,6 @@ jobs: cd main npm run build - build: - name: Build test (against pinned v12) - # Don't run pinned version checks for PRs. - if: ${{ !github.base_ref }} - runs-on: ubuntu-latest - steps: - - name: Checkout experimentation plugin - uses: actions/checkout@v4 - - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - - - name: NPM install - run: npm install - - - name: Verify build - run: npm run build - lint: name: Eslint check timeout-minutes: 5