feat: Icon ARIA (#9805)

* feat: Icon ARIA

* fix: code review

* fix: remove same listener object
This commit is contained in:
Michael Harvey
2026-05-04 15:27:46 -04:00
committed by GitHub
parent f458058187
commit 4bfbd35041
13 changed files with 272 additions and 26 deletions
+1
View File
@@ -809,6 +809,7 @@ export abstract class Bubble
*/
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
this.ariaLabelProvider = provider;
this.recomputeAriaContext();
}
/**
@@ -63,6 +63,9 @@ export class TextInputBubble extends Bubble {
/** View responsible for supporting text editing. */
private editor: CommentEditor;
private readonly textChangeListener = () => {
this.recomputeAriaContext();
};
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -85,6 +88,7 @@ export class TextInputBubble extends Bubble {
this.contentContainer.appendChild(this.editor.getDom());
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
this.addTextChangeListener(this.textChangeListener);
}
/** @returns the text of this bubble. */
@@ -287,6 +291,14 @@ export class TextInputBubble extends Bubble {
performAction() {
getFocusManager().focusNode(this.getEditor());
}
/**
* Dispose of this bubble.
*/
dispose() {
super.dispose();
this.editor.removeTextChangeListener(this.textChangeListener);
}
}
Css.register(`
@@ -13,6 +13,7 @@ import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import type {ISerializable} from '../interfaces/i_serializable.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils.js';
import * as dom from '../utils/dom.js';
@@ -336,6 +337,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
'comment',
),
);
if (this.svgRoot) {
this.recomputeAriaContext();
}
}
/** See IHasBubble.getBubble. */
@@ -376,6 +380,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
this.textInputBubble.addLocationChangeListener(() =>
this.onBubbleLocationChange(),
);
this.textInputBubble.setAriaLabelProvider(() =>
Msg['BUBBLE_LABEL_COMMENT'].replace('%1', this.getText()),
);
}
/** Hides any open bubbles owned by this comment. */
@@ -403,6 +410,19 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
private getBubbleOwnerRect(): Rect {
return (this.sourceBlock as BlockSvg).getBoundingRectangleWithoutChildren();
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_COMMENT_OPEN']
: Msg['ICON_LABEL_COMMENT_CLOSED'];
}
}
/** The save state format for a comment icon. */
+27
View File
@@ -12,8 +12,10 @@ import type {IContextMenu} from '../interfaces/i_contextmenu.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import * as tooltip from '../tooltip.js';
import {aria} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
@@ -75,6 +77,7 @@ export abstract class Icon implements IIcon, IContextMenu {
);
(this.svgRoot as any).tooltip = this;
tooltip.bindMouseEvents(this.svgRoot);
this.recomputeAriaContext();
}
dispose(): void {
@@ -219,4 +222,28 @@ export abstract class Icon implements IIcon, IContextMenu {
showContextMenu(e: PointerEvent) {
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
}
/**
* Recomputes the ARIA label and role for this icon. This is automatically called
* during initialization, but implementations may find it useful to call this if
* the icon's label should be changed.
*/
protected recomputeAriaContext(): void {
const element = this.getFocusableElement();
if (!element) return;
aria.setRole(element, aria.Role.BUTTON);
const label = this.getAriaLabel() ?? Msg['ICON_LABEL_DEFAULT'];
aria.setState(element, aria.State.LABEL, label);
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected getAriaLabel(): string | null {
return null;
}
}
@@ -15,6 +15,7 @@ import {isBlockChange, isBlockCreate} from '../events/predicates.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
@@ -184,6 +185,9 @@ export class MutatorIcon extends Icon implements IHasBubble {
this.miniWorkspaceBubble?.addWorkspaceChangeListener(
this.createMiniWorkspaceChangeListener(),
);
this.miniWorkspaceBubble.setAriaLabelProvider(
Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'],
);
} else {
this.miniWorkspaceBubble?.dispose();
this.miniWorkspaceBubble = null;
@@ -202,6 +206,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
'mutator',
),
);
this.recomputeAriaContext();
}
/** See IHasBubble.getBubble. */
@@ -358,4 +363,17 @@ export class MutatorIcon extends Icon implements IHasBubble {
getWorkspace(): WorkspaceSvg | undefined {
return this.miniWorkspaceBubble?.getWorkspace();
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_MUTATOR_OPEN']
: Msg['ICON_LABEL_MUTATOR_CLOSED'];
}
}
@@ -12,6 +12,7 @@ import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {Msg} from '../msg.js';
import * as renderManagement from '../render_management.js';
import {Size} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -185,6 +186,9 @@ export class WarningIcon extends Icon implements IHasBubble {
this,
);
this.applyColour();
this.textBubble.setAriaLabelProvider(() =>
Msg['BUBBLE_LABEL_WARNING'].replace('%1', this.getText()),
);
} else {
this.textBubble?.dispose();
this.textBubble = null;
@@ -197,6 +201,7 @@ export class WarningIcon extends Icon implements IHasBubble {
'warning',
),
);
this.recomputeAriaContext();
}
/** See IHasBubble.getBubble. */
@@ -224,4 +229,17 @@ export class WarningIcon extends Icon implements IHasBubble {
const bbox = this.sourceBlock.getSvgRoot().getBBox();
return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width);
}
/**
* Returns the ARIA label to use for this icon (defaults to null). Note that this
* method will only be called during initialization by default, so dynamic changes
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
*
* @returns The ARIA label to use for this icon, or null to use a default.
*/
protected override getAriaLabel(): string | null {
return this.bubbleIsVisible()
? Msg['ICON_LABEL_WARNING_OPEN']
: Msg['ICON_LABEL_WARNING_CLOSED'];
}
}
+11 -2
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-30 15:41:41.211465",
"lastupdated": "2026-05-01 14:09:40.345417",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -504,5 +504,14 @@
"FIELD_LABEL_VARIABLE": "Variable '%1'",
"ARIA_LABEL_BUTTON": "button",
"ARIA_LABEL_HEADING": "heading",
"BUBBLE_LABEL_DEFAULT": "Bubble"
"BUBBLE_LABEL_DEFAULT": "Bubble",
"BUBBLE_LABEL_COMMENT": "Comment: %1",
"BUBBLE_LABEL_WARNING": "Warning: %1",
"ICON_LABEL_DEFAULT": "Icon",
"ICON_LABEL_COMMENT_CLOSED": "Open Comment",
"ICON_LABEL_COMMENT_OPEN": "Close Comment",
"ICON_LABEL_MUTATOR_CLOSED": "Edit this block",
"ICON_LABEL_MUTATOR_OPEN": "Close block editor",
"ICON_LABEL_WARNING_CLOSED": "Open Warning",
"ICON_LABEL_WARNING_OPEN": "Close Warning"
}
+10 -1
View File
@@ -512,5 +512,14 @@
"FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''",
"ARIA_LABEL_BUTTON": "Part of an aria label for an element that indicates it is a button, but for technical reasons cannot be give a role of button. Ideally, this would match the localized name for what screenreaders announce for <button> elements in your language.",
"ARIA_LABEL_HEADING": "Part of an aria label for an element that indicates it is a heading, but for technial reasons cannot be given a role of heading. Ideally, this would match the localized name for what screenreaders announce for <h1> elements in your language.",
"BUBBLE_LABEL_DEFAULT": "Default label for bubbles. This is only used if a bubble is created without a label provider."
"BUBBLE_LABEL_DEFAULT": "Default label for bubbles. This is only used if a bubble is created without a label provider.",
"BUBBLE_LABEL_COMMENT": "Label for a comment bubble. Placeholder corresponds to the content of the comment. \n\nParameters:\n* %1 - the content of the comment \n\nExamples:\n* 'Comment: This block does something important.'",
"BUBBLE_LABEL_WARNING": "Label for a warning bubble. Placeholder corresponds to the content of the warning. \n\nParameters:\n* %1 - the content of the warning \n\nExamples:\n* 'Warning: Something went wrong with this block.'",
"ICON_LABEL_DEFAULT": "Label for an icon, used by screen readers to identify it.",
"ICON_LABEL_COMMENT_CLOSED": "Label for an icon, used by screen readers to identify a closed comment. Clicking on the icon opens the comment's bubble, which allows the user to read the comment.",
"ICON_LABEL_COMMENT_OPEN": "Label for an icon, used by screen readers to identify an open comment. Clicking on the icon closes the comment's bubble.",
"ICON_LABEL_MUTATOR_CLOSED": "Label for an icon, used by screen readers to identify a closed mutator. Clicking on the icon opens the mutator's bubble, which allows the user to edit the block's structure.",
"ICON_LABEL_MUTATOR_OPEN": "Label for an icon, used by screen readers to identify an open mutator. Clicking on the icon closes the mutator's bubble.",
"ICON_LABEL_WARNING_CLOSED": "Label for an icon, used by screen readers to identify a closed warning. Clicking on the icon opens the warning's bubble, which allows the user read the warning.",
"ICON_LABEL_WARNING_OPEN": "Label for an icon, used by screen readers to identify an open warning. Clicking on the icon closes the warning's bubble."
}
+31
View File
@@ -2007,3 +2007,34 @@ Blockly.Msg.ARIA_LABEL_HEADING = 'heading';
/** @type {string} */
/// Default label for bubbles. This is only used if a bubble is created without a label provider.
Blockly.Msg.BUBBLE_LABEL_DEFAULT = 'Bubble';
/** @type {string} */
/// Label for a comment bubble. Placeholder corresponds to the content of the comment.
/// \n\nParameters:\n* %1 - the content of the comment
/// \n\nExamples:\n* "Comment: This block does something important."
Blockly.Msg.BUBBLE_LABEL_COMMENT = 'Comment: %1';
/** @type {string} */
/// Label for a warning bubble. Placeholder corresponds to the content of the warning.
/// \n\nParameters:\n* %1 - the content of the warning
/// \n\nExamples:\n* "Warning: Something went wrong with this block."
Blockly.Msg.BUBBLE_LABEL_WARNING = 'Warning: %1';
/** @type {string} */
/// Label for an icon, used by screen readers to identify it.
Blockly.Msg.ICON_LABEL_DEFAULT = 'Icon';
/** @type {string} */
/// Label for an icon, used by screen readers to identify a closed comment. Clicking on the icon opens the comment's bubble, which allows the user to read the comment.
Blockly.Msg.ICON_LABEL_COMMENT_CLOSED = 'Open Comment';
/** @type {string} */
/// Label for an icon, used by screen readers to identify an open comment. Clicking on the icon closes the comment's bubble.
Blockly.Msg.ICON_LABEL_COMMENT_OPEN = 'Close Comment';
/** @type {string} */
/// Label for an icon, used by screen readers to identify a closed mutator. Clicking on the icon opens the mutator's bubble, which allows the user to edit the block's structure.
Blockly.Msg.ICON_LABEL_MUTATOR_CLOSED = 'Edit this block';
/** @type {string} */
/// Label for an icon, used by screen readers to identify an open mutator. Clicking on the icon closes the mutator's bubble.
Blockly.Msg.ICON_LABEL_MUTATOR_OPEN = 'Close block editor';
/** @type {string} */
/// Label for an icon, used by screen readers to identify a closed warning. Clicking on the icon opens the warning's bubble, which allows the user read the warning.
Blockly.Msg.ICON_LABEL_WARNING_CLOSED = 'Open Warning';
/** @type {string} */
/// Label for an icon, used by screen readers to identify an open warning. Clicking on the icon closes the warning's bubble.
Blockly.Msg.ICON_LABEL_WARNING_OPEN = 'Close Warning';
+35 -5
View File
@@ -1897,26 +1897,56 @@ suite('Blocks', function () {
suite('ARIA', function () {
setup(async function () {
this.block.setWarningText('Warning Text');
this.block.setWarningText('Something went wrong');
this.block.initSvg();
this.block.render();
const icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
icon.performAction();
await Blockly.renderManagement.finishQueuedRenders();
this.icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
await this.icon.setBubbleVisible(true);
this.bubble = icon.getBubble();
this.bubble = this.icon.getBubble();
});
function getFocusableAriaLabel(iFocusable) {
return iFocusable.getFocusableElement().getAttribute('aria-label');
}
test('Bubble has ARIA label', async function () {
assert.isTrue(
this.bubble.focusableElement.hasAttribute('aria-label'),
);
});
test('Bubble has working ARIA label provider', function () {
const label = getFocusableAriaLabel(this.bubble);
assert.include(label, 'Warning');
assert.include(label, 'Something went wrong');
});
test('Bubble has ARIA role of group', async function () {
assert.equal(
this.bubble.focusableElement.getAttribute('role'),
'group',
);
});
test('Bubble uses function provider ARIA label when provided', function () {
this.bubble.setAriaLabelProvider(() => 'Custom warning label');
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Custom warning label');
});
test('Bubble uses string provider ARIA label when provided', function () {
this.bubble.setAriaLabelProvider('Custom warning label');
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Custom warning label');
});
test('Mutator icon label changes when bubble is opened', async function () {
const openLabel = getFocusableAriaLabel(this.icon);
assert.equal(openLabel, 'Close Warning');
await this.icon.setBubbleVisible(false);
const closedLabel = getFocusableAriaLabel(this.icon);
assert.equal(closedLabel, 'Open Warning');
});
test('Bubble uses default ARIA label when no provider is set', function () {
this.bubble.setAriaLabelProvider(null);
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Bubble');
});
});
});
+34 -11
View File
@@ -216,31 +216,54 @@ suite('Comments', function () {
setup(async function () {
const block = this.workspace.newBlock('empty_block');
block.setCommentText('test text');
const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT);
await commentIcon.setBubbleVisible(true);
this.bubble = commentIcon.getBubble();
this.icon = block.getIcon(Blockly.icons.IconType.COMMENT);
await this.icon.setBubbleVisible(true);
this.bubble = this.icon.getBubble();
});
function getFocusableAriaLabel(iFocusable) {
return iFocusable.getFocusableElement().getAttribute('aria-label');
}
test('Bubble has ARIA label', function () {
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
});
test('Bubble has working ARIA label provider', function () {
const label = getFocusableAriaLabel(this.bubble);
assert.include(label, 'Comment');
assert.include(label, 'test text');
});
test('Bubble has ARIA role of group', function () {
assert.equal(this.bubble.focusableElement.getAttribute('role'), 'group');
});
test('Bubble can use AriaLabelProvider function', function () {
this.bubble.setAriaLabelProvider(() => 'comment aria label');
this.bubble.recomputeAriaContext();
assert.equal(
this.bubble.focusableElement.getAttribute('aria-label'),
'comment aria label',
);
assert.equal(getFocusableAriaLabel(this.bubble), 'comment aria label');
});
test('Bubble can use AriaLabelProvider string', function () {
this.bubble.setAriaLabelProvider('comment aria label');
this.bubble.recomputeAriaContext();
assert.equal(
this.bubble.focusableElement.getAttribute('aria-label'),
'comment aria label',
);
assert.equal(getFocusableAriaLabel(this.bubble), 'comment aria label');
});
test('Icon label changes when bubble is opened', async function () {
const openLabel = getFocusableAriaLabel(this.icon);
assert.equal(openLabel, 'Close Comment');
await this.icon.setBubbleVisible(false);
const closedLabel = getFocusableAriaLabel(this.icon);
assert.equal(closedLabel, 'Open Comment');
});
test('Bubble uses default ARIA label when no provider is set', function () {
this.bubble.setAriaLabelProvider(null);
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Bubble');
});
test('Bubble ARIA label updates when comment text changes', function () {
const initialLabel = getFocusableAriaLabel(this.bubble);
assert.include(initialLabel, 'test text');
this.bubble.editor.setText('updated text');
const updatedLabel = getFocusableAriaLabel(this.bubble);
assert.include(updatedLabel, 'updated text');
});
});
});
+17
View File
@@ -428,4 +428,21 @@ suite('Icon', function () {
}
});
});
suite('ARIA', function () {
setup(function () {
const workspace = createWorkspaceSvg();
const block = createInitializedBlock(workspace);
const icon = new TestIcon(block);
block.addIcon(icon);
this.element = icon.getFocusableElement();
});
test('Generic icons use button role', function () {
const role = this.element.getAttribute('role');
assert.equal(role, 'button');
});
test('Generic icons default to "Icon" ARIA label', function () {
const label = this.element.getAttribute('aria-label');
assert.equal(label, 'Icon');
});
});
});
+38 -7
View File
@@ -88,20 +88,51 @@ suite('Mutator', function () {
setup(async function () {
this.workspace = Blockly.inject('blocklyDiv', {});
const block = createRenderedBlock(this.workspace, 'controls_if');
const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE);
await icon.setBubbleVisible(true);
this.bubble = icon.getBubble();
this.icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE);
await this.icon.setBubbleVisible(true);
this.bubble = this.icon.getBubble();
});
function getFocusableAriaLabel(iFocusable) {
return iFocusable.getFocusableElement().getAttribute('aria-label');
}
teardown(function () {
sharedTestTeardown.call(this);
});
test('Bubble has ARIA label', async function () {
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
test('Bubble has working ARIA label provider', function () {
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Block editor workspace');
});
test('Bubble has ARIA role of group', async function () {
assert.equal(this.bubble.focusableElement.getAttribute('role'), 'group');
test('Bubble has ARIA role of group', function () {
assert.equal(
this.bubble.getFocusableElement().getAttribute('role'),
'group',
);
});
test('Bubble uses function provider ARIA label when provided', function () {
this.bubble.setAriaLabelProvider(() => 'Custom mutator label');
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Custom mutator label');
});
test('Bubble uses string provider ARIA label when provided', function () {
this.bubble.setAriaLabelProvider('Custom mutator label');
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Custom mutator label');
});
test('Mutator icon label changes when bubble is opened', async function () {
const openLabel = getFocusableAriaLabel(this.icon);
assert.equal(openLabel, 'Close block editor');
await this.icon.setBubbleVisible(false);
const closedLabel = getFocusableAriaLabel(this.icon);
assert.equal(closedLabel, 'Edit this block');
});
test('Bubble uses default ARIA label when no provider is set', function () {
this.bubble.setAriaLabelProvider(null);
const label = getFocusableAriaLabel(this.bubble);
assert.equal(label, 'Bubble');
});
});
});