Skip to content

Commit 76c7345

Browse files
authored
fix: Toolbox & Flyout ARIA positions (experimental) (#9394)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9386 Fixes part of #9293 ### Proposed Changes Addresses #9386 through a number of changes: - Ensures the flyout contents are reevaluated for ARIA changes whenever they themselves change (since previously `WorkspaceSvg` only recomputed its ARIA properties when one of its blocks self-registered which doesn't account for labels or buttons). - Collapsible categories are now correctly wrapped by a group (since groups of tree items must be in a parent group). - Updates toolbox ARIA computations to be on content changes rather than having items self-specify recomputing the ARIA properties. This mitigates an issue with collapsible categories not updating the toolbox contents and thus being omitted. - Introduced a separate pathway for computing tree info for flyout workspaces (vs. for the main workspace) in order to account for `FlyoutButton`s. - Updated `FlyoutButton` to use a nested structure of `treeitem` then `button` in order to actually count correctly in the tree and still be an interactive button. The readout experience is actually better now on ChromeVox, as well, since it reads out _both_ 'Button' and 'Tree item' which is interesting. It seems screen readers are designed to look for this pattern but it only works if set up in a very particular way. ### Reason for Changes Most of the changes here fixed incidental problems noticed while trying to address #9386 (such as the variables category not correctly accounting for the 'Create variable' button in the count, or not having the correct labels). Much of the count issues in the original issue were caused by a combination of missing some flyout items, and trying to compute the labels too early (i.e. before the categories were fully populated). ### Test Coverage Since this is an experimental change, no new tests were added. ### Documentation No documentation changes are directly needed here. ### Additional Information None.
1 parent 1dd7701 commit 76c7345

File tree

8 files changed

+84
-49
lines changed

8 files changed

+84
-49
lines changed

core/block_svg.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class BlockSvg
262262

263263
collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] {
264264
// NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The
265-
// returned list needs to be relatively stable for consistency block indexes
265+
// returned list needs to be relatively stable for consistent block indexes
266266
// read out to users via screen readers.
267267
if (surroundParent) {
268268
// Start from the first sibling and iterate in navigation order.

core/flyout_base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,9 @@ export abstract class Flyout
534534
*/
535535
setContents(contents: FlyoutItem[]): void {
536536
this.contents = contents;
537+
this.workspace_.recomputeAriaTree();
537538
}
539+
538540
/**
539541
* Update the display property of the flyout based whether it thinks it should
540542
* be visible and whether its containing workspace is visible.

core/flyout_button.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ export class FlyoutButton
6060
height = 0;
6161

6262
/** The root SVG group for the button or label. */
63-
private svgGroup: SVGGElement;
63+
private svgContainerGroup: SVGGElement;
64+
65+
/** The root SVG group for the button's or label's contents. */
66+
private svgContentGroup: SVGGElement;
67+
68+
/** The SVG group that can hold focus for this button or label. */
69+
private svgFocusableGroup: SVGGElement;
6470

6571
/** The SVG element with the text of the label or button. */
6672
private svgText: SVGTextElement | null = null;
@@ -112,19 +118,27 @@ export class FlyoutButton
112118
}
113119

114120
this.id = idGenerator.getNextUniqueId();
115-
this.svgGroup = dom.createSvgElement(
121+
this.svgContainerGroup = dom.createSvgElement(
116122
Svg.G,
117-
{'id': this.id, 'class': cssClass, 'tabindex': '-1'},
123+
{'class': cssClass},
118124
this.workspace.getCanvas(),
119125
);
126+
this.svgContentGroup = dom.createSvgElement(
127+
Svg.G,
128+
{},
129+
this.svgContainerGroup,
130+
);
120131

121-
// Set the accessibility role. All child nodes will be set to `presentation`
122-
// to avoid extraneous "group" narration on VoiceOver.
132+
aria.setRole(this.svgContainerGroup, aria.Role.TREEITEM);
123133
if (this.isFlyoutLabel) {
124-
aria.setRole(this.svgGroup, aria.Role.TREEITEM);
134+
aria.setRole(this.svgContentGroup, aria.Role.PRESENTATION);
135+
this.svgFocusableGroup = this.svgContainerGroup;
125136
} else {
126-
aria.setRole(this.svgGroup, aria.Role.BUTTON);
137+
aria.setRole(this.svgContentGroup, aria.Role.BUTTON);
138+
this.svgFocusableGroup = this.svgContentGroup;
127139
}
140+
this.svgFocusableGroup.id = this.id;
141+
this.svgFocusableGroup.tabIndex = -1;
128142

129143
let shadow;
130144
if (!this.isFlyoutLabel) {
@@ -138,7 +152,7 @@ export class FlyoutButton
138152
'x': 1,
139153
'y': 1,
140154
},
141-
this.svgGroup!,
155+
this.svgContentGroup,
142156
);
143157
aria.setRole(shadow, aria.Role.PRESENTATION);
144158
}
@@ -152,7 +166,7 @@ export class FlyoutButton
152166
'rx': FlyoutButton.BORDER_RADIUS,
153167
'ry': FlyoutButton.BORDER_RADIUS,
154168
},
155-
this.svgGroup!,
169+
this.svgContentGroup,
156170
);
157171
aria.setRole(rect, aria.Role.PRESENTATION);
158172

@@ -164,7 +178,7 @@ export class FlyoutButton
164178
'y': 0,
165179
'text-anchor': 'middle',
166180
},
167-
this.svgGroup!,
181+
this.svgContentGroup,
168182
);
169183
if (!this.isFlyoutLabel) {
170184
aria.setRole(svgText, aria.Role.PRESENTATION);
@@ -181,6 +195,7 @@ export class FlyoutButton
181195
.getThemeManager()
182196
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
183197
}
198+
aria.setState(this.svgFocusableGroup, aria.State.LABEL, text);
184199

185200
const fontSize = style.getComputedStyle(svgText, 'fontSize');
186201
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');
@@ -217,13 +232,13 @@ export class FlyoutButton
217232
this.updateTransform();
218233

219234
this.onMouseDownWrapper = browserEvents.conditionalBind(
220-
this.svgGroup,
235+
this.svgContentGroup,
221236
'pointerdown',
222237
this,
223238
this.onMouseDown,
224239
);
225240
this.onMouseUpWrapper = browserEvents.conditionalBind(
226-
this.svgGroup,
241+
this.svgContentGroup,
227242
'pointerup',
228243
this,
229244
this.onMouseUp,
@@ -233,18 +248,18 @@ export class FlyoutButton
233248
createDom(): SVGElement {
234249
// No-op, now handled in constructor. Will be removed in followup refactor
235250
// PR that updates the flyout classes to use inflaters.
236-
return this.svgGroup;
251+
return this.svgContainerGroup;
237252
}
238253

239254
/** Correctly position the flyout button and make it visible. */
240255
show() {
241256
this.updateTransform();
242-
this.svgGroup!.setAttribute('display', 'block');
257+
this.svgContainerGroup!.setAttribute('display', 'block');
243258
}
244259

245260
/** Update SVG attributes to match internal state. */
246261
private updateTransform() {
247-
this.svgGroup!.setAttribute(
262+
this.svgContainerGroup!.setAttribute(
248263
'transform',
249264
'translate(' + this.position.x + ',' + this.position.y + ')',
250265
);
@@ -330,8 +345,8 @@ export class FlyoutButton
330345
dispose() {
331346
browserEvents.unbind(this.onMouseDownWrapper);
332347
browserEvents.unbind(this.onMouseUpWrapper);
333-
if (this.svgGroup) {
334-
dom.removeNode(this.svgGroup);
348+
if (this.svgContainerGroup) {
349+
dom.removeNode(this.svgContainerGroup);
335350
}
336351
if (this.svgText) {
337352
this.workspace.getThemeManager().unsubscribe(this.svgText);
@@ -349,8 +364,8 @@ export class FlyoutButton
349364
this.cursorSvg = null;
350365
return;
351366
}
352-
if (this.svgGroup) {
353-
this.svgGroup.appendChild(cursorSvg);
367+
if (this.svgContainerGroup) {
368+
this.svgContentGroup.appendChild(cursorSvg);
354369
this.cursorSvg = cursorSvg;
355370
}
356371
}
@@ -398,12 +413,12 @@ export class FlyoutButton
398413
* @returns The root SVG element of this rendered element.
399414
*/
400415
getSvgRoot() {
401-
return this.svgGroup;
416+
return this.svgContainerGroup;
402417
}
403418

404419
/** See IFocusableNode.getFocusableElement. */
405420
getFocusableElement(): HTMLElement | SVGElement {
406-
return this.svgGroup;
421+
return this.svgFocusableGroup;
407422
}
408423

409424
/** See IFocusableNode.getFocusableTree. */

core/toolbox/category.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import type {
3030
StaticCategoryInfo,
3131
} from '../utils/toolbox.js';
3232
import * as toolbox from '../utils/toolbox.js';
33-
import {Toolbox} from './toolbox.js';
3433
import {ToolboxItem} from './toolbox_item.js';
3534

3635
/**
@@ -193,7 +192,6 @@ export class ToolboxCategory
193192
aria.setRole(this.htmlDiv_, aria.Role.TREEITEM);
194193
aria.setState(this.htmlDiv_, aria.State.SELECTED, false);
195194
aria.setState(this.htmlDiv_, aria.State.LEVEL, this.level_ + 1);
196-
(this.parentToolbox_ as Toolbox).recomputeAriaOwners();
197195

198196
this.rowDiv_ = this.createRowContainer_();
199197
this.rowDiv_.style.pointerEvents = 'auto';

core/toolbox/collapsible_category.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import * as dom from '../utils/dom.js';
2020
import * as toolbox from '../utils/toolbox.js';
2121
import {ToolboxCategory} from './category.js';
2222
import {ToolboxSeparator} from './separator.js';
23-
import {Toolbox} from './toolbox.js';
2423

2524
/**
2625
* Class for a category in a toolbox that can be collapsed.
@@ -136,8 +135,7 @@ export class CollapsibleToolboxCategory
136135
this.htmlDiv_!.appendChild(this.subcategoriesDiv_);
137136
this.closeIcon_(this.iconDom_);
138137
aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false);
139-
140-
aria.setRole(this.htmlDiv_!, aria.Role.GROUP);
138+
aria.setRole(this.htmlDiv_!, aria.Role.TREEITEM);
141139

142140
// Ensure this group has properly set children.
143141
const selectableChildren =
@@ -150,7 +148,6 @@ export class CollapsibleToolboxCategory
150148
aria.State.OWNS,
151149
[...new Set(focusableChildIds)].join(' '),
152150
);
153-
(this.parentToolbox_ as Toolbox).recomputeAriaOwners();
154151

155152
return this.htmlDiv_!;
156153
}
@@ -194,6 +191,7 @@ export class CollapsibleToolboxCategory
194191
newCategory.getClickTarget()?.setAttribute('id', newCategory.getId());
195192
}
196193
}
194+
aria.setRole(contentsContainer, aria.Role.GROUP);
197195
return contentsContainer;
198196
}
199197

core/toolbox/separator.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import * as registry from '../registry.js';
1717
import * as aria from '../utils/aria.js';
1818
import * as dom from '../utils/dom.js';
1919
import type * as toolbox from '../utils/toolbox.js';
20-
import {Toolbox} from './toolbox.js';
2120
import {ToolboxItem} from './toolbox_item.js';
2221

2322
/**
@@ -67,7 +66,6 @@ export class ToolboxSeparator extends ToolboxItem {
6766
this.htmlDiv = container;
6867

6968
aria.setRole(this.htmlDiv, aria.Role.SEPARATOR);
70-
(this.parentToolbox_ as Toolbox).recomputeAriaOwners();
7169

7270
return container;
7371
}

core/toolbox/toolbox.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ export class Toolbox
369369
this.renderContents_(toolboxDef['contents']);
370370
this.position();
371371
this.handleToolboxItemResize();
372+
this.recomputeAriaOwners();
372373
}
373374

374375
/**
@@ -445,6 +446,7 @@ export class Toolbox
445446
this.addToolboxItem_(child);
446447
}
447448
}
449+
this.recomputeAriaOwners();
448450
}
449451

450452
/**

core/workspace_svg.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import './events/events_theme_change.js';
1919
import './events/events_viewport.js';
2020

2121
import type {Block} from './block.js';
22-
import type {BlockSvg} from './block_svg.js';
22+
import {BlockSvg} from './block_svg.js';
2323
import type {BlocklyOptions} from './blockly_options.js';
2424
import * as browserEvents from './browser_events.js';
2525
import {TextInputBubble} from './bubbles/textinput_bubble.js';
@@ -42,7 +42,7 @@ import {Abstract as AbstractEvent} from './events/events.js';
4242
import {EventType} from './events/type.js';
4343
import * as eventUtils from './events/utils.js';
4444
import {Flyout} from './flyout_base.js';
45-
import type {FlyoutButton} from './flyout_button.js';
45+
import {FlyoutButton} from './flyout_button.js';
4646
import {getFocusManager} from './focus_manager.js';
4747
import {Gesture} from './gesture.js';
4848
import {Grid} from './grid.js';
@@ -52,10 +52,7 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js';
5252
import {IContextMenu} from './interfaces/i_contextmenu.js';
5353
import type {IDragTarget} from './interfaces/i_drag_target.js';
5454
import type {IFlyout} from './interfaces/i_flyout.js';
55-
import {
56-
isFocusableNode,
57-
type IFocusableNode,
58-
} from './interfaces/i_focusable_node.js';
55+
import {type IFocusableNode} from './interfaces/i_focusable_node.js';
5956
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
6057
import {hasBubble} from './interfaces/i_has_bubble.js';
6158
import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
@@ -2745,10 +2742,7 @@ export class WorkspaceSvg
27452742
return (
27462743
flyout
27472744
.getContents()
2748-
.find((flyoutItem) => {
2749-
const element = flyoutItem.getElement();
2750-
return isFocusableNode(element) && element.canBeFocused();
2751-
})
2745+
.find((flyoutItem) => flyoutItem.getElement().canBeFocused())
27522746
?.getElement() ?? null
27532747
);
27542748
}
@@ -2805,11 +2799,7 @@ export class WorkspaceSvg
28052799
if (this.isFlyout && flyout) {
28062800
for (const flyoutItem of flyout.getContents()) {
28072801
const elem = flyoutItem.getElement();
2808-
if (
2809-
isFocusableNode(elem) &&
2810-
elem.canBeFocused() &&
2811-
elem.getFocusableElement().id === id
2812-
) {
2802+
if (elem.canBeFocused() && elem.getFocusableElement().id === id) {
28132803
return elem;
28142804
}
28152805
}
@@ -2947,10 +2937,42 @@ export class WorkspaceSvg
29472937
}
29482938

29492939
recomputeAriaTree() {
2950-
// TODO: Do this efficiently (probably incrementally).
2951-
this.getTopBlocks(false).forEach((block) =>
2952-
this.recomputeAriaTreeItemDetailsRecursively(block),
2953-
);
2940+
// Flyout workspaces require special arrangement to account for items.
2941+
const flyout = this.targetWorkspace?.getFlyout();
2942+
if (this.isFlyout && flyout) {
2943+
const focusableItems = flyout
2944+
.getContents()
2945+
.map((item) => item.getElement())
2946+
.filter((item) => item.canBeFocused());
2947+
focusableItems.forEach((item, index) => {
2948+
// This is rather hacky and may need more thought, but it's a
2949+
// consequence of actual button (non-label) FlyoutButtons requiring two
2950+
// distinct roles (a parent treeitem and a child button that actually
2951+
// holds focus).
2952+
// TODO: Figure out how to generalize this for arbitrary FlyoutItems
2953+
// that may require special handling like this (i.e. a treeitem wrapping
2954+
// an actual focusable element).
2955+
const treeItemElem =
2956+
item instanceof FlyoutButton
2957+
? item.getSvgRoot()
2958+
: item.getFocusableElement();
2959+
aria.setState(treeItemElem, aria.State.POSINSET, index + 1);
2960+
aria.setState(treeItemElem, aria.State.SETSIZE, focusableItems.length);
2961+
aria.setState(treeItemElem, aria.State.LEVEL, 1); // They are always top-level.
2962+
if (item instanceof BlockSvg) {
2963+
item
2964+
.getChildren(false)
2965+
.forEach((child) =>
2966+
this.recomputeAriaTreeItemDetailsRecursively(child),
2967+
);
2968+
}
2969+
});
2970+
} else {
2971+
// TODO: Do this efficiently (probably incrementally).
2972+
this.getTopBlocks(false).forEach((block) =>
2973+
this.recomputeAriaTreeItemDetailsRecursively(block),
2974+
);
2975+
}
29542976
}
29552977

29562978
private recomputeAriaTreeItemDetailsRecursively(block: BlockSvg) {

0 commit comments

Comments
 (0)