Skip to content

Commit fad610f

Browse files
committed
chore: Split up monkey patches & type them.
1 parent 630137a commit fad610f

27 files changed

+589
-514
lines changed

src/aria_monkey_patches.js

Lines changed: 0 additions & 512 deletions
This file was deleted.

src/navigation_controller.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@ import {Mover} from './actions/mover';
3636
import {DuplicateAction} from './actions/duplicate';
3737
import {StackNavigationAction} from './actions/stack_navigation';
3838

39-
import './aria_monkey_patches';
39+
import './screenreader/aria_monkey_patcher';
40+
import {FunctionStubber} from './screenreader/function_stubber_registry';
4041

4142
const KeyCodes = BlocklyUtils.KeyCodes;
4243

44+
// Note that prototype stubs must happen early in the page lifecycle in order to
45+
// take effect before Blockly loading.
46+
FunctionStubber.getInstance().stubPrototypes();
47+
4348
/**
4449
* Class for registering shortcuts for keyboard navigation.
4550
*/
@@ -292,5 +297,7 @@ export class NavigationController {
292297
}
293298
this.removeShortcutHandlers();
294299
this.navigation.dispose();
300+
301+
FunctionStubber.getInstance().unstubPrototypes();
295302
}
296303
}
File renamed without changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* @fileoverview Overrides a bunch of methods throughout core Blockly in order
9+
* to augment Blockly components with ARIA support.
10+
*/
11+
12+
import * as aria from './aria';
13+
import './stuboverrides/override_block_svg'
14+
import './stuboverrides/override_collapsible_toolbox_category'
15+
import './stuboverrides/override_comment_icon'
16+
import './stuboverrides/override_field_checkbox'
17+
import './stuboverrides/override_field_dropdown'
18+
import './stuboverrides/override_field_image'
19+
import './stuboverrides/override_field_input'
20+
import './stuboverrides/override_field_label'
21+
import './stuboverrides/override_field'
22+
import './stuboverrides/override_flyout_button'
23+
import './stuboverrides/override_icon'
24+
import './stuboverrides/override_mutator_icon'
25+
import './stuboverrides/override_rendered_connection'
26+
import './stuboverrides/override_rendered_workspace_comment'
27+
import './stuboverrides/override_toolbox_category'
28+
import './stuboverrides/override_toolbox_separator'
29+
import './stuboverrides/override_toolbox'
30+
import './stuboverrides/override_warning_icon'
31+
import './stuboverrides/override_workspace_svg'
32+
33+
const oldCreateElementNS = document.createElementNS;
34+
35+
document.createElementNS = function (namepspaceURI, qualifiedName) {
36+
const element = oldCreateElementNS.call(this, namepspaceURI, qualifiedName);
37+
// Top-level SVG elements and groups are presentation by default. They will be
38+
// specified more specifically elsewhere if they need to be readable.
39+
if (qualifiedName === 'svg' || qualifiedName === 'g') {
40+
aria.setRole(element, aria.Role.PRESENTATION);
41+
}
42+
return element;
43+
};
44+
45+
const oldElementSetAttribute = Element.prototype.setAttribute;
46+
// TODO: Replace these cases with property augmentation here so that all aria
47+
// behavior is defined within this file.
48+
const ariaAttributeAllowlist = ['aria-disabled', 'aria-selected'];
49+
50+
Element.prototype.setAttribute = function (name, value) {
51+
// This is a hacky way to disable all aria changes in core Blockly since it's
52+
// easier to just undefine everything globally and then conditionally reenable
53+
// things with the correct definitions.
54+
// TODO: Add an exemption for role here once all roles are properly defined
55+
// within this file (see failing tests when role changes are ignored here).
56+
if (
57+
aria.isCurrentlyMutatingAriaProperty() ||
58+
ariaAttributeAllowlist.includes(name) ||
59+
!name.startsWith('aria-')
60+
) {
61+
oldElementSetAttribute.call(this, name, value);
62+
}
63+
};
64+
65+
// 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.
66+
// TODO: Ditto for CommentBarButton and its children.
67+
// TODO: Ditto for Bubble and its children.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as Blockly from 'blockly/core';
2+
import * as aria from './aria';
3+
4+
export function computeBlockAriaLabel(block: Blockly.BlockSvg): string {
5+
// Guess the block's aria label based on its field labels.
6+
if (block.isShadow()) {
7+
// TODO: Shadows may have more than one field.
8+
// Shadow blocks are best represented directly by their field since they
9+
// effectively operate like a field does for keyboard navigation purposes.
10+
const field = Array.from(block.getFields())[0];
11+
return aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?';
12+
}
13+
14+
const fieldLabels = [];
15+
for (const field of block.getFields()) {
16+
if (field instanceof Blockly.FieldLabel) {
17+
fieldLabels.push(field.getText());
18+
}
19+
}
20+
return fieldLabels.join(' ');
21+
};
22+
23+
function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.BlockSvg | null): Blockly.BlockSvg[] {
24+
// NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The
25+
// returned list needs to be relatively stable for consistency block indexes
26+
// read out to users via screen readers.
27+
if (surroundParent) {
28+
// Start from the first sibling and iterate in navigation order.
29+
const firstSibling: Blockly.BlockSvg = surroundParent.getChildren(false)[0];
30+
const siblings: Blockly.BlockSvg[] = [firstSibling];
31+
let nextSibling: Blockly.BlockSvg | null = firstSibling;
32+
while (nextSibling = nextSibling.getNextBlock()) {
33+
siblings.push(nextSibling);
34+
}
35+
return siblings;
36+
} else {
37+
// For top-level blocks, simply return those from the workspace.
38+
return block.workspace.getTopBlocks(false);
39+
}
40+
}
41+
42+
function computeLevelInWorkspace(block: Blockly.BlockSvg): number {
43+
const surroundParent = block.getSurroundParent();
44+
return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0;
45+
};
46+
47+
// TODO: Do this efficiently (probably centrally).
48+
export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) {
49+
const elem = block.getFocusableElement();
50+
const connection = (block as any).currentConnectionCandidate;
51+
let childPosition: number;
52+
let parentsChildCount: number;
53+
let hierarchyDepth: number;
54+
if (connection) {
55+
// If the block is being inserted into a new location, the position is hypothetical.
56+
// TODO: Figure out how to deal with output connections.
57+
let surroundParent: Blockly.BlockSvg | null;
58+
let siblingBlocks: Blockly.BlockSvg[];
59+
if (connection.type === Blockly.ConnectionType.INPUT_VALUE) {
60+
surroundParent = connection.sourceBlock_;
61+
siblingBlocks = collectSiblingBlocks(block, surroundParent);
62+
// The block is being added as a child since it's input.
63+
// TODO: Figure out how to compute the correct position.
64+
childPosition = 1;
65+
} else {
66+
surroundParent = connection.sourceBlock_.getSurroundParent();
67+
siblingBlocks = collectSiblingBlocks(block, surroundParent);
68+
// The block is being added after the connected block.
69+
childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2;
70+
}
71+
parentsChildCount = siblingBlocks.length + 1;
72+
hierarchyDepth = surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 1;
73+
} else {
74+
const surroundParent = block.getSurroundParent();
75+
const siblingBlocks = collectSiblingBlocks(block, surroundParent);
76+
childPosition = siblingBlocks.indexOf(block) + 1;
77+
parentsChildCount = siblingBlocks.length;
78+
hierarchyDepth = computeLevelInWorkspace(block) + 1;
79+
}
80+
aria.setState(elem, aria.State.POSINSET, childPosition);
81+
aria.setState(elem, aria.State.SETSIZE, parentsChildCount);
82+
aria.setState(elem, aria.State.LEVEL, hierarchyDepth);
83+
block.getChildren(false).forEach((child) => recomputeAriaTreeItemDetailsRecursively(child));
84+
}
85+
86+
export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMoving: boolean, isCanceled: boolean, newLoc?: Blockly.utils.Coordinate) {
87+
const connection = (block as any).currentConnectionCandidate;
88+
if (isCanceled) {
89+
aria.announceDynamicAriaState('Canceled movement');
90+
return;
91+
}
92+
if (!isMoving) return;
93+
if (connection) {
94+
// TODO: Figure out general detachment.
95+
// TODO: Figure out how to deal with output connections.
96+
let surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_;
97+
const announcementContext = [];
98+
announcementContext.push('Moving'); // TODO: Specialize for inserting?
99+
// NB: Old code here doesn't seem to handle parents correctly.
100+
if (connection.type === Blockly.ConnectionType.INPUT_VALUE) {
101+
announcementContext.push('to', 'input');
102+
} else {
103+
announcementContext.push('to', 'child');
104+
}
105+
if (surroundParent) {
106+
announcementContext.push('of', computeBlockAriaLabel(surroundParent));
107+
}
108+
109+
// If the block is currently being moved, announce the new block label so that the user understands where it is now.
110+
// 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.
111+
aria.announceDynamicAriaState(announcementContext.join(' '));
112+
} else if (newLoc) {
113+
// The block is being freely dragged.
114+
aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`);
115+
}
116+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
export type StubCallback<T> = (instance: T, ...args: any) => void;
2+
3+
class Registration<T> {
4+
private oldMethod: ((...args: any) => any) | null = null;
5+
6+
constructor(
7+
readonly callback: StubCallback<T>,
8+
readonly methodNameToOverride: string,
9+
readonly classPrototype: T,
10+
readonly ensureOneCall: boolean
11+
) {}
12+
13+
stubPrototype(): void {
14+
// TODO: Figure out how to make this work with minification.
15+
if (this.oldMethod) {
16+
throw new Error(`Function is already stubbed: ${this.methodNameToOverride}.`);
17+
}
18+
const genericPrototype = this.classPrototype as any;
19+
const oldMethod =
20+
genericPrototype[this.methodNameToOverride] as (...args: any) => any;
21+
this.oldMethod = oldMethod;
22+
const registration = this;
23+
genericPrototype[this.methodNameToOverride] = function (...args: any): any {
24+
let stubsCalled =
25+
this._internalStubsCalled as {[key: string]: boolean} | undefined;
26+
if (!stubsCalled) {
27+
stubsCalled = {};
28+
this._internalStubsCalled = stubsCalled;
29+
}
30+
31+
const result = oldMethod.call(this, ...args);
32+
if (!registration.ensureOneCall || !stubsCalled[registration.methodNameToOverride]) {
33+
registration.callback(this as unknown as T, ...args);
34+
stubsCalled[registration.methodNameToOverride] = true;
35+
}
36+
return result;
37+
};
38+
}
39+
40+
unstubPrototype(): void {
41+
if (this.oldMethod) {
42+
throw new Error(`Function is not currently stubbed: ${this.methodNameToOverride}.`);
43+
}
44+
const genericPrototype = this.classPrototype as any;
45+
genericPrototype[this.methodNameToOverride] = this.oldMethod;
46+
this.oldMethod = null;
47+
}
48+
}
49+
50+
export class FunctionStubber {
51+
private registrations: Registration<any>[] = [];
52+
private isFinalized: boolean = false;
53+
54+
public registerInitializationStub<T>(
55+
callback: StubCallback<T>,
56+
methodNameToOverride: string,
57+
classPrototype: T
58+
) {
59+
if (this.isFinalized) {
60+
throw new Error('Cannot register a stub after initialization has been completed.');
61+
}
62+
const registration = new Registration(callback, methodNameToOverride, classPrototype, true);
63+
this.registrations.push(registration);
64+
}
65+
66+
public registerMethodStub<T>(
67+
callback: StubCallback<T>,
68+
methodNameToOverride: string,
69+
classPrototype: T
70+
) {
71+
if (this.isFinalized) {
72+
throw new Error('Cannot register a stub after initialization has been completed.');
73+
}
74+
const registration = new Registration(callback, methodNameToOverride, classPrototype, false);
75+
this.registrations.push(registration);
76+
}
77+
78+
public stubPrototypes() {
79+
this.isFinalized = true;
80+
this.registrations.forEach((registration) => registration.stubPrototype());
81+
}
82+
83+
public unstubPrototypes() {
84+
this.registrations.forEach((registration) => registration.unstubPrototype());
85+
this.isFinalized = false;
86+
}
87+
88+
private static instance: FunctionStubber | null = null;
89+
90+
static getInstance(): FunctionStubber {
91+
if (!FunctionStubber.instance) {
92+
FunctionStubber.instance = new FunctionStubber();
93+
}
94+
return FunctionStubber.instance;
95+
}
96+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {FunctionStubber} from '../function_stubber_registry';
2+
import * as Blockly from 'blockly/core';
3+
import * as aria from '../aria';
4+
import * as blockSvgUtils from '../block_svg_utilities';
5+
6+
FunctionStubber.getInstance().registerInitializationStub((block) => {
7+
const svgPath = block.getFocusableElement();
8+
aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block');
9+
aria.setRole(svgPath, aria.Role.TREEITEM);
10+
aria.setState(svgPath, aria.State.LABEL, blockSvgUtils.computeBlockAriaLabel(block));
11+
svgPath.tabIndex = -1;
12+
(block as any).currentConnectionCandidate = null;
13+
}, 'doInit_', Blockly.BlockSvg.prototype);
14+
15+
FunctionStubber.getInstance().registerMethodStub((block) => {
16+
block.workspace
17+
.getTopBlocks(false)
18+
.forEach((block) => blockSvgUtils.recomputeAriaTreeItemDetailsRecursively(block));
19+
}, 'setParent', Blockly.BlockSvg.prototype);
20+
21+
FunctionStubber.getInstance().registerMethodStub((block) => {
22+
(block as any).currentConnectionCandidate =
23+
// @ts-expect-error Access to private property dragStrategy.
24+
block.dragStrategy.connectionCandidate?.neighbour ?? null;
25+
blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false);
26+
}, 'startDrag', Blockly.BlockSvg.prototype);
27+
28+
FunctionStubber.getInstance().registerMethodStub((block, newLoc: Blockly.utils.Coordinate) => {
29+
(block as any).currentConnectionCandidate =
30+
// @ts-expect-error Access to private property dragStrategy.
31+
block.dragStrategy.connectionCandidate?.neighbour ?? null;
32+
blockSvgUtils.announceDynamicAriaStateForBlock(block, true, false, newLoc);
33+
}, 'drag', Blockly.BlockSvg.prototype);
34+
35+
FunctionStubber.getInstance().registerMethodStub((block) => {
36+
(block as any).currentConnectionCandidate = null;
37+
blockSvgUtils.announceDynamicAriaStateForBlock(block, false, false);
38+
}, 'endDrag', Blockly.BlockSvg.prototype);
39+
40+
FunctionStubber.getInstance().registerMethodStub((block) => {
41+
blockSvgUtils.announceDynamicAriaStateForBlock(block, false, true);
42+
}, 'revertDrag', Blockly.BlockSvg.prototype);
43+
44+
FunctionStubber.getInstance().registerMethodStub((block) => {
45+
aria.setState(block.getFocusableElement(), aria.State.SELECTED, true);
46+
}, 'onNodeFocus', Blockly.BlockSvg.prototype);
47+
48+
FunctionStubber.getInstance().registerMethodStub((block) => {
49+
aria.setState(block.getFocusableElement(), aria.State.SELECTED, false);
50+
}, 'onNodeBlur', Blockly.BlockSvg.prototype);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {FunctionStubber} from '../function_stubber_registry';
2+
import * as Blockly from 'blockly/core';
3+
import * as aria from '../aria';
4+
import * as toolboxUtils from '../toolbox_utilities';
5+
6+
FunctionStubber.getInstance().registerInitializationStub((category) => {
7+
const element = category.getFocusableElement();
8+
aria.setRole(element, aria.Role.GROUP);
9+
10+
// Ensure this group has properly set children.
11+
const selectableChildren =
12+
category.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null;
13+
const focusableChildIds = selectableChildren.map(
14+
(selectable) => selectable.getFocusableElement().id,
15+
);
16+
aria.setState(
17+
element,
18+
aria.State.OWNS,
19+
[...new Set(focusableChildIds)].join(' '),
20+
);
21+
toolboxUtils.recomputeAriaOwnersInToolbox(category.getFocusableTree() as Blockly.Toolbox);
22+
}, 'init', Blockly.CollapsibleToolboxCategory.prototype);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {FunctionStubber} from '../function_stubber_registry';
2+
import * as Blockly from 'blockly/core';
3+
import * as aria from '../aria';
4+
5+
FunctionStubber.getInstance().registerInitializationStub((icon) => {
6+
const element = icon.getFocusableElement();
7+
aria.setState(
8+
element,
9+
aria.State.LABEL,
10+
icon.bubbleIsVisible() ? 'Close Comment' : 'Open Comment',
11+
);
12+
}, 'initView', Blockly.icons.CommentIcon.prototype);

0 commit comments

Comments
 (0)