Skip to content

Commit a94f037

Browse files
committed
chore: Add docs & remove unnecessary bits.
This addresses all lint warnings.
1 parent cc848aa commit a94f037

23 files changed

+345
-43
lines changed

src/screenreader/aria.ts

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,57 +7,43 @@
77
const ARIA_PREFIX = 'aria-';
88
const ROLE_ATTRIBUTE = 'role';
99

10-
// TODO: Finalize this.
10+
/** Represents an ARIA role that an element may have. */
1111
export enum Role {
12-
GRID = 'grid',
13-
GRIDCELL = 'gridcell',
1412
GROUP = 'group',
1513
LISTBOX = 'listbox',
16-
MENU = 'menu',
17-
MENUITEM = 'menuitem',
18-
MENUITEMCHECKBOX = 'menuitemcheckbox',
19-
OPTION = 'option',
2014
PRESENTATION = 'presentation',
21-
ROW = 'row',
2215
TREE = 'tree',
2316
TREEITEM = 'treeitem',
2417
SEPARATOR = 'separator',
25-
STATUS = 'status',
26-
REGION = 'region',
2718
IMAGE = 'image',
2819
FIGURE = 'figure',
2920
BUTTON = 'button',
3021
CHECKBOX = 'checkbox',
3122
TEXTBOX = 'textbox',
32-
APPLICATION = 'application',
3323
}
3424

35-
// TODO: Finalize this.
25+
/** Represents ARIA-specific state that can be configured for an element. */
3626
export enum State {
37-
ACTIVEDESCENDANT = 'activedescendant',
38-
COLCOUNT = 'colcount',
39-
DISABLED = 'disabled',
40-
EXPANDED = 'expanded',
41-
INVALID = 'invalid',
4227
LABEL = 'label',
43-
LABELLEDBY = 'labelledby',
4428
LEVEL = 'level',
45-
ORIENTATION = 'orientation',
4629
POSINSET = 'posinset',
47-
ROWCOUNT = 'rowcount',
4830
SELECTED = 'selected',
4931
SETSIZE = 'setsize',
50-
VALUEMAX = 'valuemax',
51-
VALUEMIN = 'valuemin',
5232
LIVE = 'live',
5333
HIDDEN = 'hidden',
5434
ROLEDESCRIPTION = 'roledescription',
55-
ATOMIC = 'atomic',
5635
OWNS = 'owns',
5736
}
5837

5938
let isMutatingAriaProperty = false;
6039

40+
/**
41+
* Updates the specific role for the specified element.
42+
*
43+
* @param element The element whose ARIA role should be changed.
44+
* @param roleName The new role for the specified element, or null if its role
45+
* should be cleared.
46+
*/
6147
export function setRole(element: Element, roleName: Role | null) {
6248
isMutatingAriaProperty = true;
6349
if (roleName) {
@@ -66,6 +52,13 @@ export function setRole(element: Element, roleName: Role | null) {
6652
isMutatingAriaProperty = false;
6753
}
6854

55+
/**
56+
* Returns the ARIA role of the specified element, or null if it either doesn't
57+
* have a designated role or if that role is unknown.
58+
*
59+
* @param element The element from which to retrieve its ARIA role.
60+
* @returns The ARIA role of the element, or null if undefined or unknown.
61+
*/
6962
export function getRole(element: Element): Role | null {
7063
// This is an unsafe cast which is why it needs to be checked to ensure that
7164
// it references a valid role.
@@ -76,6 +69,18 @@ export function getRole(element: Element): Role | null {
7669
return null;
7770
}
7871

72+
/**
73+
* Sets the specified ARIA state by its name and value for the specified
74+
* element.
75+
*
76+
* Note that the type of value is not validated against the specific type of
77+
* state being changed, so it's up to callers to ensure the correct value is
78+
* used for the given state.
79+
*
80+
* @param element The element whose ARIA state may be changed.
81+
* @param stateName The state to change.
82+
* @param value The new value to specify for the provided state.
83+
*/
7984
export function setState(
8085
element: Element,
8186
stateName: State,
@@ -90,11 +95,39 @@ export function setState(
9095
isMutatingAriaProperty = false;
9196
}
9297

98+
/**
99+
* Returns a string representation of the specified state for the specified
100+
* element, or null if it's not defined or specified.
101+
*
102+
* Note that an explicit set state of 'null' will return the 'null' string, not
103+
* the value null.
104+
*
105+
* @param element The element whose state is being retrieved.
106+
* @param stateName The state to retrieve.
107+
* @returns The string representation of the requested state for the specified
108+
* element, or null if not defined.
109+
*/
93110
export function getState(element: Element, stateName: State): string | null {
94111
const attrStateName = ARIA_PREFIX + stateName;
95112
return element.getAttribute(attrStateName);
96113
}
97114

115+
/**
116+
* Softly requests that the specified text be read to the user if a screen
117+
* reader is currently active.
118+
*
119+
* This relies on a centrally managed ARIA live region that should not interrupt
120+
* existing announcements (that is, this is what's considered a polite
121+
* announcement).
122+
*
123+
* Callers should use this judiciously. It's often considered bad practice to
124+
* over announce information that can be inferred from other sources on the
125+
* page, so this ought to only be used when certain context cannot be easily
126+
* determined (such as dynamic states that may not have perfect ARIA
127+
* representations or indications).
128+
*
129+
* @param text The text to politely read to the user.
130+
*/
98131
export function announceDynamicAriaState(text: string) {
99132
const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce');
100133
if (!ariaAnnouncementSpan) {
@@ -103,6 +136,12 @@ export function announceDynamicAriaState(text: string) {
103136
ariaAnnouncementSpan.innerHTML = text;
104137
}
105138

139+
/**
140+
* Determines whether an ARIA property is in the process of being changed.
141+
*
142+
* @returns Returns whether an ARIA property is changing for any element,
143+
* specifically via setRole() or stateState().
144+
*/
106145
export function isCurrentlyMutatingAriaProperty(): boolean {
107146
return isMutatingAriaProperty;
108147
}

src/screenreader/block_svg_utilities.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
17
import * as Blockly from 'blockly/core';
28
import * as aria from './aria';
39

10+
/**
11+
* Computes the human-readable ARIA label for the specified block.
12+
*
13+
* @param block The block whose label should be computed.
14+
* @returns A human-readable ARIA label/representation for the block.
15+
*/
416
export function computeBlockAriaLabel(block: Blockly.BlockSvg): string {
517
// Guess the block's aria label based on its field labels.
618
if (block.isShadow()) {
@@ -49,12 +61,27 @@ function computeLevelInWorkspace(block: Blockly.BlockSvg): number {
4961
return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0;
5062
}
5163

52-
// TODO: Do this efficiently (probably centrally).
53-
export function recomputeAriaTreeItemDetailsRecursively(
54-
block: Blockly.BlockSvg,
64+
/**
65+
* Recomputes all BlockSvg ARIA tree structures in the workspace.
66+
*
67+
* This is a fairly expensive operation and should ideally only be performed
68+
* when a block structure or relationship change has been made.
69+
*
70+
* @param workspace The workspace whose top-level blocks may need a tree
71+
* structure recomputation.
72+
*/
73+
export function recomputeAllWorkspaceAriaTrees(
74+
workspace: Blockly.WorkspaceSvg,
5575
) {
76+
// TODO: Do this efficiently (probably increementally).
77+
workspace
78+
.getTopBlocks(false)
79+
.forEach((block) => recomputeAriaTreeItemDetailsRecursively(block));
80+
}
81+
82+
function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) {
5683
const elem = block.getFocusableElement();
57-
const connection = (block as any).currentConnectionCandidate;
84+
const connection = getCurrentConnectionCandidate(block);
5885
let childPosition: number;
5986
let parentsChildCount: number;
6087
let hierarchyDepth: number;
@@ -94,13 +121,26 @@ export function recomputeAriaTreeItemDetailsRecursively(
94121
.forEach((child) => recomputeAriaTreeItemDetailsRecursively(child));
95122
}
96123

124+
/**
125+
* Announces the current dynamic state of the specified block, if any.
126+
*
127+
* An example of dynamic state is whether the block is currently being moved,
128+
* and in what way. These states aren't represented through ARIA directly, so
129+
* they need to be determined and announced using an ARIA live region
130+
* (see aria.announceDynamicAriaState).
131+
*
132+
* @param block The block whose dynamic state should maybe be announced.
133+
* @param isMoving Whether the specified block is currently being moved.
134+
* @param isCanceled Whether the previous movement operation has been canceled.
135+
* @param newLoc The new location the block is moving to (if unconstrained).
136+
*/
97137
export function announceDynamicAriaStateForBlock(
98138
block: Blockly.BlockSvg,
99139
isMoving: boolean,
100140
isCanceled: boolean,
101141
newLoc?: Blockly.utils.Coordinate,
102142
) {
103-
const connection = (block as any).currentConnectionCandidate;
143+
const connection = getCurrentConnectionCandidate(block);
104144
if (isCanceled) {
105145
aria.announceDynamicAriaState('Canceled movement');
106146
return;
@@ -132,3 +172,33 @@ export function announceDynamicAriaStateForBlock(
132172
);
133173
}
134174
}
175+
176+
interface ConnectionCandidateHolder {
177+
currentConnectionCandidate: Blockly.RenderedConnection | null;
178+
}
179+
180+
function getCurrentConnectionCandidate(
181+
block: Blockly.BlockSvg,
182+
): Blockly.RenderedConnection | null {
183+
const connectionHolder = block as unknown as ConnectionCandidateHolder;
184+
return connectionHolder.currentConnectionCandidate;
185+
}
186+
187+
/**
188+
* Updates the current connection candidate for the specified block (that is,
189+
* the connection the block is being connected to).
190+
*
191+
* This corresponds to a temporary property used when determining specifics of
192+
* a block's location when being moved.
193+
*
194+
* @param block The block which may have a new connection candidate.
195+
* @param connection The latest connection candidate for the block, or null if
196+
* none.
197+
*/
198+
export function setCurrentConnectionCandidate(
199+
block: Blockly.BlockSvg,
200+
connection: Blockly.RenderedConnection | null,
201+
) {
202+
const connectionHolder = block as unknown as ConnectionCandidateHolder;
203+
connectionHolder.currentConnectionCandidate = connection;
204+
}

src/screenreader/function_stubber_registry.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* A function callback used to run after an overridden stub method using
9+
* FunctionStubber.
10+
*/
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112
export type StubCallback<T> = (instance: T, ...args: any) => void;
213

314
class Registration<T> {
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
416
private oldMethod: ((...args: any) => any) | null = null;
517

618
constructor(
@@ -17,13 +29,17 @@ class Registration<T> {
1729
`Function is already stubbed: ${this.methodNameToOverride}.`,
1830
);
1931
}
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2033
const genericPrototype = this.classPrototype as any;
2134
const oldMethod = genericPrototype[this.methodNameToOverride] as (
35+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2236
...args: any
37+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2338
) => any;
2439
this.oldMethod = oldMethod;
2540
// eslint-disable-next-line @typescript-eslint/no-this-alias
2641
const registration = this;
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2743
genericPrototype[this.methodNameToOverride] = function (...args: any): any {
2844
let stubsCalled = this._internalStubsCalled as
2945
| {[key: string]: boolean}
@@ -46,10 +62,43 @@ class Registration<T> {
4662
}
4763
}
4864

65+
/**
66+
* Utility for augmenting a class's functionality by monkey-patching a
67+
* function's prototype in order to call a custom function.
68+
*
69+
* Note that all custom functions are always run after the original function
70+
* runs. This order cannot be configured, nor can the original function be
71+
* disabled.
72+
*
73+
* There are two types of overrides possible: initialization via
74+
* registerInitializationStub() and regular class methods via
75+
* registerMethodStub().
76+
*
77+
* Instances of this class should retrieved using getInstance().
78+
*
79+
* IMPORTANT: In order for stubbing to work correctly, see the caveats of
80+
* stubPrototypes().
81+
*/
4982
export class FunctionStubber {
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5084
private registrations: Array<Registration<any>> = [];
5185
private isFinalized = false;
5286

87+
/**
88+
* Registers a new initialization stub.
89+
*
90+
* Initialization stub callbacks are only invoked once per instance of a given
91+
* object, even if that function is called multiple times. This allows for
92+
* methods called in a class's constructor to be used as a proxy for the
93+
* constructor itself.
94+
*
95+
* This will throw an error if called after stubPrototypes() has been called.
96+
*
97+
* @param callback The function to run when the stubbed method executes for
98+
* the first time.
99+
* @param methodNameToOverride The name of the method to override.
100+
* @param classPrototype The prototype of the class being stubbed.
101+
*/
53102
registerInitializationStub<T>(
54103
callback: StubCallback<T>,
55104
methodNameToOverride: string,
@@ -69,6 +118,18 @@ export class FunctionStubber {
69118
this.registrations.push(registration);
70119
}
71120

121+
/**
122+
* Registers a new method stub.
123+
*
124+
* Method stub callbacks are invoked every time the overridden method is
125+
* invoked.
126+
*
127+
* This will throw an error if called after stubPrototypes() has been called.
128+
*
129+
* @param callback The function to run when the stubbed method executes.
130+
* @param methodNameToOverride The name of the method to override.
131+
* @param classPrototype The prototype of the class being stubbed.
132+
*/
72133
registerMethodStub<T>(
73134
callback: StubCallback<T>,
74135
methodNameToOverride: string,
@@ -88,13 +149,23 @@ export class FunctionStubber {
88149
this.registrations.push(registration);
89150
}
90151

152+
/**
153+
* Performs the actual monkey-patching to enable the custom registered
154+
* callbacks from registerInitializationStub() and registerMethodStub() to
155+
* work correctly.
156+
*
157+
* IMPORTANT: This must be called after all registration is completed, and
158+
* before any of the stubbed classes are actually used. This cannot be undone
159+
* (that is, there is no deregistration).
160+
*/
91161
stubPrototypes() {
92162
this.isFinalized = true;
93163
this.registrations.forEach((registration) => registration.stubPrototype());
94164
}
95165

96166
private static instance: FunctionStubber | null = null;
97167

168+
/** Returns the page-global instance of this FunctionStubber. */
98169
static getInstance(): FunctionStubber {
99170
if (!FunctionStubber.instance) {
100171
FunctionStubber.instance = new FunctionStubber();

0 commit comments

Comments
 (0)