Skip to content

Commit d0ad934

Browse files
authored
feat: Add initial support for screen readers (experimental) (#9280)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes part of #8207 Fixes part of #3370 ### Proposed Changes This introduces initial broad ARIA integration in order to enable at least basic screen reader support when using keyboard navigation. Largely this involves introducing ARIA roles and labels in a bunch of places, sometimes done in a way to override normal built-in behaviors of the accessibility node tree in order to get a richer first-class output for Blockly (such as for blocks and workspaces). ### Reason for Changes ARIA is the fundamental basis for configuring how focusable nodes in Blockly are represented to the user when using a screen reader. As such, all focusable nodes requires labels and roles in order to correctly communicate their contexts. The specific approach taken in this PR is to simply add labels and roles to all nodes where obvious with some extra work done for `WorkspaceSvg` and `BlockSvg` in order to represent blocks as a tree (since that seems to be the best fitting ARIA role per those available: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles). The custom work specifically for blocks includes: - Overriding the role description to be 'block' rather than 'tree item' (which is the default). - Overriding the position, level, and number of sibling counts since those are normally determined based on the DOM tree and blocks are not laid out in the tree the same way they are visually or logically (so these computations were incorrect). This is also the reason for a bunch of extra computation logic being introduced. One note on some of the labels being nonsensical (e.g. 'DoNotOverride?'): this was done intentionally to try and ensure _all_ focusable nodes (that can be focused) have labels, even when the specifics of what that label should be aren't yet clear. More components had these temporary labels until testing revealed how exactly they would behave from a screen reader perspective (at which point their roles and labels were updated as needed). The temporary labels act as an indicator when navigating through the UI, and some of the nodes can't easily be reached (for reasons) and thus may never actually need a label. More work is needed in understanding both what components need labels and what those labels should be, but that will be done beyond this PR. ### Test Coverage No tests are added to this as it's experimental and not a final implementation. The keyboard navigation tests are failing due to a visibility expansion of `connectionCandidate` in `BlockDragStrategy`. There's no way to avoid this breakage, unfortunately. Instead, this PR will be merged and then RaspberryPiFoundation/blockly-keyboard-experimentation#684 will be finalized and merged to fix it. There's some additional work that will happen both in that branch and in a later PR in core Blockly to integrate the two experimentation branches as part of #9283 so that CI passes correctly for both branches. ### Documentation No documentation is needed at this time. ### Additional Information This work is experimental and is meant to serve two purposes: - Provide a foundation for testing and iterating the core screen reader experience in Blockly. - Provide a reference point for designing a long-term solution that accounts for all requirements collected during user testing. This code should never be merged into `develop` as it stands. Instead, it will be redesigned with maintainability, testing, and correctness in mind at a future date (see RaspberryPiFoundation/blockly-keyboard-experimentation#673).
1 parent af57a3e commit d0ad934

29 files changed

+472
-43
lines changed

core/block_svg.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import * as blocks from './serialization/blocks.js';
5656
import type {BlockStyle} from './theme.js';
5757
import * as Tooltip from './tooltip.js';
5858
import {idGenerator} from './utils.js';
59+
import * as aria from './utils/aria.js';
5960
import {Coordinate} from './utils/coordinate.js';
6061
import * as dom from './utils/dom.js';
6162
import {Rect} from './utils/rect.js';
@@ -168,6 +169,8 @@ export class BlockSvg
168169
/** Whether this block is currently being dragged. */
169170
private dragging = false;
170171

172+
public currentConnectionCandidate: RenderedConnection | null = null;
173+
171174
/**
172175
* The location of the top left of this block (in workspace coordinates)
173176
* relative to either its parent block, or the workspace origin if it has no
@@ -215,7 +218,69 @@ export class BlockSvg
215218
// The page-wide unique ID of this Block used for focusing.
216219
svgPath.id = idGenerator.getNextUniqueId();
217220

221+
aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block');
222+
aria.setRole(svgPath, aria.Role.TREEITEM);
223+
svgPath.tabIndex = -1;
224+
this.currentConnectionCandidate = null;
225+
218226
this.doInit_();
227+
228+
// Note: This must be done after initialization of the block's fields.
229+
this.recomputeAriaLabel();
230+
}
231+
232+
private recomputeAriaLabel() {
233+
aria.setState(
234+
this.getFocusableElement(),
235+
aria.State.LABEL,
236+
this.computeAriaLabel(),
237+
);
238+
}
239+
240+
private computeAriaLabel(): string {
241+
// Guess the block's aria label based on its field labels.
242+
if (this.isShadow()) {
243+
// TODO: Shadows may have more than one field.
244+
// Shadow blocks are best represented directly by their field since they
245+
// effectively operate like a field does for keyboard navigation purposes.
246+
const field = Array.from(this.getFields())[0];
247+
return (
248+
aria.getState(field.getFocusableElement(), aria.State.LABEL) ??
249+
'Unknown?'
250+
);
251+
}
252+
253+
const fieldLabels = [];
254+
for (const field of this.getFields()) {
255+
if (field instanceof FieldLabel) {
256+
fieldLabels.push(field.getText());
257+
}
258+
}
259+
return fieldLabels.join(' ');
260+
}
261+
262+
collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] {
263+
// NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The
264+
// returned list needs to be relatively stable for consistency block indexes
265+
// read out to users via screen readers.
266+
if (surroundParent) {
267+
// Start from the first sibling and iterate in navigation order.
268+
const firstSibling: BlockSvg = surroundParent.getChildren(false)[0];
269+
const siblings: BlockSvg[] = [firstSibling];
270+
let nextSibling: BlockSvg | null = firstSibling;
271+
while ((nextSibling = nextSibling.getNextBlock())) {
272+
siblings.push(nextSibling);
273+
}
274+
return siblings;
275+
} else {
276+
// For top-level blocks, simply return those from the workspace.
277+
return this.workspace.getTopBlocks(false);
278+
}
279+
}
280+
281+
computeLevelInWorkspace(): number {
282+
const surroundParent = this.getSurroundParent();
283+
return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0;
219284
}
220285

221286
/**
@@ -266,12 +331,14 @@ export class BlockSvg
266331
select() {
267332
this.addSelect();
268333
common.fireSelectedEvent(this);
334+
aria.setState(this.getFocusableElement(), aria.State.SELECTED, true);
269335
}
270336

271337
/** Unselects this block. Unhighlights the block visually. */
272338
unselect() {
273339
this.removeSelect();
274340
common.fireSelectedEvent(null);
341+
aria.setState(this.getFocusableElement(), aria.State.SELECTED, false);
275342
}
276343

277344
/**
@@ -342,6 +409,8 @@ export class BlockSvg
342409
}
343410

344411
this.applyColour();
412+
413+
this.workspace.recomputeAriaTree();
345414
}
346415

347416
/**
@@ -1776,21 +1845,32 @@ export class BlockSvg
17761845
/** Starts a drag on the block. */
17771846
startDrag(e?: PointerEvent): void {
17781847
this.dragStrategy.startDrag(e);
1848+
const dragStrategy = this.dragStrategy as BlockDragStrategy;
1849+
const candidate = dragStrategy.connectionCandidate?.neighbour ?? null;
1850+
this.currentConnectionCandidate = candidate;
1851+
this.announceDynamicAriaState(true, false);
17791852
}
17801853

17811854
/** Drags the block to the given location. */
17821855
drag(newLoc: Coordinate, e?: PointerEvent): void {
17831856
this.dragStrategy.drag(newLoc, e);
1857+
const dragStrategy = this.dragStrategy as BlockDragStrategy;
1858+
const candidate = dragStrategy.connectionCandidate?.neighbour ?? null;
1859+
this.currentConnectionCandidate = candidate;
1860+
this.announceDynamicAriaState(true, false, newLoc);
17841861
}
17851862

17861863
/** Ends the drag on the block. */
17871864
endDrag(e?: PointerEvent): void {
17881865
this.dragStrategy.endDrag(e);
1866+
this.currentConnectionCandidate = null;
1867+
this.announceDynamicAriaState(false, false);
17891868
}
17901869

17911870
/** Moves the block back to where it was at the start of a drag. */
17921871
revertDrag(): void {
17931872
this.dragStrategy.revertDrag();
1873+
this.announceDynamicAriaState(false, true);
17941874
}
17951875

17961876
/**
@@ -1855,4 +1935,53 @@ export class BlockSvg
18551935
canBeFocused(): boolean {
18561936
return true;
18571937
}
1938+
1939+
/**
1940+
* Announces the current dynamic state of the specified block, if any.
1941+
*
1942+
* An example of dynamic state is whether the block is currently being moved,
1943+
* and in what way. These states aren't represented through ARIA directly, so
1944+
* they need to be determined and announced using an ARIA live region
1945+
* (see aria.announceDynamicAriaState).
1946+
*
1947+
* @param isMoving Whether the specified block is currently being moved.
1948+
* @param isCanceled Whether the previous movement operation has been canceled.
1949+
* @param newLoc The new location the block is moving to (if unconstrained).
1950+
*/
1951+
private announceDynamicAriaState(
1952+
isMoving: boolean,
1953+
isCanceled: boolean,
1954+
newLoc?: Coordinate,
1955+
) {
1956+
if (isCanceled) {
1957+
aria.announceDynamicAriaState('Canceled movement');
1958+
return;
1959+
}
1960+
if (!isMoving) return;
1961+
if (this.currentConnectionCandidate) {
1962+
// TODO: Figure out general detachment.
1963+
// TODO: Figure out how to deal with output connections.
1964+
const surroundParent = this.currentConnectionCandidate.sourceBlock_;
1965+
const announcementContext = [];
1966+
announcementContext.push('Moving'); // TODO: Specialize for inserting?
1967+
// NB: Old code here doesn't seem to handle parents correctly.
1968+
if (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) {
1969+
announcementContext.push('to', 'input');
1970+
} else {
1971+
announcementContext.push('to', 'child');
1972+
}
1973+
if (surroundParent) {
1974+
announcementContext.push('of', surroundParent.computeAriaLabel());
1975+
}
1976+
1977+
// If the block is currently being moved, announce the new block label so that the user understands where it is now.
1978+
// 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.
1979+
aria.announceDynamicAriaState(announcementContext.join(' '));
1980+
} else if (newLoc) {
1981+
// The block is being freely dragged.
1982+
aria.announceDynamicAriaState(
1983+
`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`,
1984+
);
1985+
}
1986+
}
18581987
}

core/bubbles/bubble.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js';
1515
import {ISelectable} from '../interfaces/i_selectable.js';
1616
import {ContainerRegion} from '../metrics_manager.js';
1717
import {Scrollbar} from '../scrollbar.js';
18+
import * as aria from '../utils/aria.js';
1819
import {Coordinate} from '../utils/coordinate.js';
1920
import * as dom from '../utils/dom.js';
2021
import * as idGenerator from '../utils/idgenerator.js';
@@ -142,6 +143,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
142143

143144
this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
144145
this.focusableElement.setAttribute('id', this.id);
146+
aria.setRole(this.focusableElement, aria.Role.GROUP);
147+
aria.setState(this.focusableElement, aria.State.LABEL, 'Bubble');
145148

146149
browserEvents.conditionalBind(
147150
this.background,

core/comments/collapse_comment_bar_button.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import * as browserEvents from '../browser_events.js';
88
import * as touch from '../touch.js';
9+
import * as aria from '../utils/aria.js';
910
import * as dom from '../utils/dom.js';
1011
import {Svg} from '../utils/svg.js';
1112
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -69,6 +70,11 @@ export class CollapseCommentBarButton extends CommentBarButton {
6970
browserEvents.unbind(this.bindId);
7071
}
7172

73+
override initAria(): void {
74+
aria.setRole(this.icon, aria.Role.BUTTON);
75+
aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?');
76+
}
77+
7278
/**
7379
* Adjusts the positioning of this button within its container.
7480
*/

core/comments/comment_bar_button.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export abstract class CommentBarButton implements IFocusableNode {
5252
return comment;
5353
}
5454

55+
abstract initAria(): void;
56+
5557
/** Adjusts the position of this button within its parent container. */
5658
abstract reposition(): void;
5759

core/comments/comment_editor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {getFocusManager} from '../focus_manager.js';
99
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
1010
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
1111
import * as touch from '../touch.js';
12+
import * as aria from '../utils/aria.js';
1213
import * as dom from '../utils/dom.js';
1314
import {Size} from '../utils/size.js';
1415
import {Svg} from '../utils/svg.js';
@@ -54,6 +55,8 @@ export class CommentEditor implements IFocusableNode {
5455
) as HTMLTextAreaElement;
5556
this.textArea.setAttribute('tabindex', '-1');
5657
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
58+
aria.setRole(this.textArea, aria.Role.TEXTBOX);
59+
aria.setState(this.textArea, aria.State.LABEL, 'DoNotDefine?');
5760
dom.addClass(this.textArea, 'blocklyCommentText');
5861
dom.addClass(this.textArea, 'blocklyTextarea');
5962
dom.addClass(this.textArea, 'blocklyText');

core/comments/comment_view.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node';
1010
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
1111
import * as layers from '../layers.js';
1212
import * as touch from '../touch.js';
13+
import * as aria from '../utils/aria.js';
1314
import {Coordinate} from '../utils/coordinate.js';
1415
import * as dom from '../utils/dom.js';
1516
import * as drag from '../utils/drag.js';
@@ -108,6 +109,9 @@ export class CommentView implements IRenderedElement {
108109
'class': 'blocklyComment blocklyEditable blocklyDraggable',
109110
});
110111

112+
aria.setRole(this.svgRoot, aria.Role.TEXTBOX);
113+
aria.setState(this.svgRoot, aria.State.LABEL, 'DoNotOverride?');
114+
111115
this.highlightRect = this.createHighlightRect(this.svgRoot);
112116

113117
({

core/comments/delete_comment_bar_button.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as browserEvents from '../browser_events.js';
88
import {getFocusManager} from '../focus_manager.js';
99
import * as touch from '../touch.js';
10+
import * as aria from '../utils/aria.js';
1011
import * as dom from '../utils/dom.js';
1112
import {Svg} from '../utils/svg.js';
1213
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -69,6 +70,11 @@ export class DeleteCommentBarButton extends CommentBarButton {
6970
browserEvents.unbind(this.bindId);
7071
}
7172

73+
override initAria(): void {
74+
aria.setRole(this.icon, aria.Role.BUTTON);
75+
aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?');
76+
}
77+
7278
/**
7379
* Adjusts the positioning of this button within its container.
7480
*/

core/css.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,4 +507,12 @@ input[type=number] {
507507
) {
508508
outline: none;
509509
}
510+
511+
#blocklyAriaAnnounce {
512+
position: absolute;
513+
left: -9999px;
514+
width: 1px;
515+
height: px;
516+
overflow: hidden;
517+
}
510518
`;

core/dragging/block_drag_strategy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class BlockDragStrategy implements IDragStrategy {
5050

5151
private startLoc: Coordinate | null = null;
5252

53-
private connectionCandidate: ConnectionCandidate | null = null;
53+
public connectionCandidate: ConnectionCandidate | null = null;
5454

5555
private connectionPreviewer: IConnectionPreviewer | null = null;
5656

core/field.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {ISerializable} from './interfaces/i_serializable.js';
3131
import type {ConstantProvider} from './renderers/common/constants.js';
3232
import type {KeyboardShortcut} from './shortcut_registry.js';
3333
import * as Tooltip from './tooltip.js';
34+
import * as aria from './utils/aria.js';
3435
import type {Coordinate} from './utils/coordinate.js';
3536
import * as dom from './utils/dom.js';
3637
import * as idGenerator from './utils/idgenerator.js';
@@ -403,6 +404,7 @@ export abstract class Field<T = any>
403404
}
404405
this.textContent_ = document.createTextNode('');
405406
this.textElement_.appendChild(this.textContent_);
407+
aria.setState(this.textElement_, aria.State.HIDDEN, true);
406408
}
407409

408410
/**

0 commit comments

Comments
 (0)