Skip to content

Commit bb342f9

Browse files
authored
feat: Make Flyout an ARIA list (experimental) (#9528)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9495 ### Proposed Changes Changes a bunch of ARIA role & label management to ensure that `Flyout` acts like a list rather than a tree. ### Reason for Changes `Flyout`s are always hierarchically flat so it doesn't make sense to model them as a tree. Instead, a menu is likely a better fit per https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menu_role: > A `menu` generally represents a grouping of common actions or functions that the user can invoke. The `menu` role is appropriate when a list of menu items is presented in a manner similar to a menu on a desktop application. Submenus, also known as pop-up menus, also have the role `menu`. However, there are important caveats that need to be considered and addressed: - As discussed below, menus introduce some unexpected compatibility issues with VoiceOver so this PR presently uses `list` and `listitem`s as a slightly more generic enumerating alternative for menus. - Menus (and to some extent lists) are stricter\* than trees in that they seem to impose a requirement that `menuitem`s cannot contain interactive elements (they are expected to be interactive themselves). This has led to a few specific changes: - Block children are now hidden when in the flyout (since they aren't navigable anyway). - Flyout buttons are themselves now the `menuitem` rather than their container parent, and they no longer use the role button. - Menus aren't really expected to contain labels but it isn't inherently disallowed. This is less of an issue with lists. - Because everything must be a `listitem` (or a few more specific alternatives) both blocks and buttons lack some context. Since not all `Flyout` items can be expected to be interactive, buttons and blocks have both had their labels updated to include an explicit indicator that they are buttons and blocks, respectively. Note that this does possibly go against convention for buttons in particular but it seems fine since this is an unusual (but seemingly correct) utilization of a `list` element. - To further provide context on blocks, the generated label for blocks in the `Flyout` is now its verbose label rather than the more compact form. \* This is largely a consequence of a few specific attributes of `menuitem` and `menu`s as a whole and very likely also applies to `tree`s and `treeitem`s (and `list`s and `listitems`s). However, now seemed like a good time to fix this especially in case some screen readers get confused rather than ignore nested interactive controls/follow semantic cloaking per the spec. Demo of it working on VoiceOver (per @gonfunko -- note this was the `menu` variant rather than the `list` variant of the PR): ![Screen Recording 2025-12-11 at 2 50 30 PM](https://github.com/user-attachments/assets/24c4389f-73c7-4cb5-96ce-d9666841cdd8) ### Test Coverage This has been manually tested with ChromeVox. No automated tests are needed as part of this experimental change. ### Documentation No new documentation changes are needed for this experimental change. ### Additional Information None.
1 parent 1216655 commit bb342f9

File tree

9 files changed

+65
-30
lines changed

9 files changed

+65
-30
lines changed

core/block_svg.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,16 @@ export class BlockSvg
239239
aria.setState(
240240
this.getFocusableElement(),
241241
aria.State.LABEL,
242-
this.computeAriaLabel(),
242+
!this.isInFlyout
243+
? this.computeAriaLabel()
244+
: this.computeAriaLabelForFlyoutBlock(),
243245
);
244246
}
245247

248+
private computeAriaLabelForFlyoutBlock(): string {
249+
return `${this.computeAriaLabel(true)}, block`;
250+
}
251+
246252
computeAriaLabel(
247253
verbose: boolean = false,
248254
minimal: boolean = false,
@@ -305,7 +311,7 @@ export class BlockSvg
305311

306312
private computeAriaRole() {
307313
if (this.workspace.isFlyout) {
308-
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
314+
aria.setRole(this.pathObject.svgPath, aria.Role.LISTITEM);
309315
} else {
310316
const roleDescription = this.getAriaRoleDescription();
311317
aria.setState(

core/field_checkbox.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,18 @@ export class FieldCheckbox extends Field<CheckboxBool> {
122122

123123
private recomputeAria() {
124124
const element = this.getFocusableElement();
125-
aria.setRole(element, aria.Role.CHECKBOX);
126-
aria.setState(
127-
element,
128-
aria.State.LABEL,
129-
this.getAriaTypeName() ?? 'Checkbox',
130-
);
131-
aria.setState(element, aria.State.CHECKED, !!this.value_);
125+
const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false;
126+
if (!isInFlyout) {
127+
aria.setRole(element, aria.Role.CHECKBOX);
128+
aria.setState(
129+
element,
130+
aria.State.LABEL,
131+
this.getAriaTypeName() ?? 'Checkbox',
132+
);
133+
aria.setState(element, aria.State.CHECKED, !!this.value_);
134+
} else {
135+
aria.setState(element, aria.State.HIDDEN, true);
136+
}
132137
}
133138

134139
override render_() {

core/field_dropdown.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,17 +208,21 @@ export class FieldDropdown extends Field<string> {
208208

209209
protected recomputeAria() {
210210
if (!this.fieldGroup_) return; // There's no element to set currently.
211+
const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false;
211212
const element = this.getFocusableElement();
212-
aria.setRole(element, aria.Role.COMBOBOX);
213-
aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX);
214-
aria.setState(element, aria.State.EXPANDED, !!this.menu_);
215-
if (this.menu_) {
216-
aria.setState(element, aria.State.CONTROLS, this.menu_.id);
213+
if (!isInFlyout) {
214+
aria.setRole(element, aria.Role.COMBOBOX);
215+
aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX);
216+
aria.setState(element, aria.State.EXPANDED, !!this.menu_);
217+
if (this.menu_) {
218+
aria.setState(element, aria.State.CONTROLS, this.menu_.id);
219+
} else {
220+
aria.clearState(element, aria.State.CONTROLS);
221+
}
222+
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
217223
} else {
218-
aria.clearState(element, aria.State.CONTROLS);
224+
aria.setState(element, aria.State.HIDDEN, true);
219225
}
220-
221-
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
222226
}
223227

224228
/**

core/field_image.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,14 @@ export class FieldImage extends Field<string> {
159159
dom.addClass(this.fieldGroup_, 'blocklyImageField');
160160
}
161161

162+
const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false;
162163
const element = this.getFocusableElement();
163-
if (this.isClickable()) {
164+
if (!isInFlyout && this.isClickable()) {
164165
this.imageElement.style.cursor = 'pointer';
165166
aria.setRole(element, aria.Role.BUTTON);
166167
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
167168
} else {
168-
// The field isn't navigable unless it's clickable.
169+
// The field isn't navigable unless it's clickable and outside the flyout.
169170
aria.setRole(element, aria.Role.PRESENTATION);
170171
}
171172
}

core/field_input.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,6 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
178178
dom.addClass(this.fieldGroup_, 'blocklyInputField');
179179
}
180180

181-
const element = this.getFocusableElement();
182-
aria.setRole(element, aria.Role.BUTTON);
183181
this.recomputeAriaLabel();
184182
}
185183

@@ -189,7 +187,13 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
189187
protected recomputeAriaLabel() {
190188
if (!this.fieldGroup_) return;
191189
const element = this.getFocusableElement();
192-
aria.setState(element, aria.State.LABEL, super.computeAriaLabel());
190+
const isInFlyout = this.getSourceBlock()?.workspace?.isFlyout || false;
191+
if (!isInFlyout) {
192+
aria.setRole(element, aria.Role.BUTTON);
193+
aria.setState(element, aria.State.LABEL, super.computeAriaLabel());
194+
} else {
195+
aria.setState(element, aria.State.HIDDEN, true);
196+
}
193197
}
194198

195199
override isFullBlockField(): boolean {

core/flyout_button.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,13 @@ export class FlyoutButton
132132
this.svgContainerGroup,
133133
);
134134

135-
aria.setRole(this.svgContainerGroup, aria.Role.TREEITEM);
136135
if (this.isFlyoutLabel) {
136+
aria.setRole(this.svgContainerGroup, aria.Role.LISTITEM);
137137
aria.setRole(this.svgContentGroup, aria.Role.PRESENTATION);
138138
this.svgFocusableGroup = this.svgContainerGroup;
139139
} else {
140-
aria.setRole(this.svgContentGroup, aria.Role.BUTTON);
140+
aria.setRole(this.svgContainerGroup, aria.Role.PRESENTATION);
141+
aria.setRole(this.svgContentGroup, aria.Role.LISTITEM);
141142
this.svgFocusableGroup = this.svgContentGroup;
142143
}
143144
this.svgFocusableGroup.id = this.id;
@@ -183,9 +184,7 @@ export class FlyoutButton
183184
},
184185
this.svgContentGroup,
185186
);
186-
if (!this.isFlyoutLabel) {
187-
aria.setRole(svgText, aria.Role.PRESENTATION);
188-
}
187+
aria.setRole(svgText, aria.Role.PRESENTATION);
189188
let text = parsing.replaceMessageReferences(this.text);
190189
if (this.workspace.RTL) {
191190
// Force text to be RTL by adding an RLM.
@@ -198,7 +197,15 @@ export class FlyoutButton
198197
.getThemeManager()
199198
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
200199
}
201-
aria.setState(this.svgFocusableGroup, aria.State.LABEL, text);
200+
if (this.isFlyoutLabel) {
201+
aria.setState(this.svgFocusableGroup, aria.State.LABEL, text);
202+
} else {
203+
aria.setState(
204+
this.svgFocusableGroup,
205+
aria.State.LABEL,
206+
`${text}, button`,
207+
);
208+
}
202209

203210
const fontSize = style.getComputedStyle(svgText, 'fontSize');
204211
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');

core/flyout_item.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export class FlyoutItem {
88
/**
99
* Creates a new FlyoutItem.
1010
*
11+
* Note that it's the responsibility of implementations to ensure that element
12+
* is given the ARIA role LISTITEM and respects its expected constraints
13+
* (which includes ensuring that no interactive elements are children of the
14+
* item element--interactive elements themselves should be the LISTITEM).
15+
*
1116
* @param element The element that will be displayed in the flyout.
1217
* @param type The type of element. Should correspond to the type of the
1318
* flyout inflater that created this object.

core/utils/aria.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export enum Role {
2828

2929
// ARIA role for menu item elements.
3030
MENUITEM = 'menuitem',
31+
3132
// ARIA role for option items that are children of combobox, listbox, menu,
3233
// radiogroup, or tree elements.
3334
OPTION = 'option',
@@ -55,6 +56,8 @@ export enum Role {
5556
SPINBUTTON = 'spinbutton',
5657
REGION = 'region',
5758
GENERIC = 'generic',
59+
LIST = 'list',
60+
LISTITEM = 'listitem',
5861
}
5962

6063
/**

core/workspace_svg.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,8 +804,8 @@ export class WorkspaceSvg
804804
this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer();
805805

806806
if (this.isFlyout) {
807-
// Use the block canvas as the primary tree parent for flyout blocks.
808-
aria.setRole(this.svgBlockCanvas_, aria.Role.TREE);
807+
// Use the block canvas as the primary list for nesting.
808+
aria.setRole(this.svgBlockCanvas_, aria.Role.LIST);
809809
aria.setState(this.svgBlockCanvas_, aria.State.LABEL, ariaLabel);
810810
} else {
811811
browserEvents.conditionalBind(

0 commit comments

Comments
 (0)