Skip to content

Commit 4bfbd35

Browse files
authored
feat: Icon ARIA (#9805)
* feat: Icon ARIA * fix: code review * fix: remove same listener object
1 parent f458058 commit 4bfbd35

13 files changed

Lines changed: 272 additions & 26 deletions

File tree

packages/blockly/core/bubbles/bubble.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,7 @@ export abstract class Bubble
809809
*/
810810
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
811811
this.ariaLabelProvider = provider;
812+
this.recomputeAriaContext();
812813
}
813814

814815
/**

packages/blockly/core/bubbles/textinput_bubble.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class TextInputBubble extends Bubble {
6363
/** View responsible for supporting text editing. */
6464
private editor: CommentEditor;
6565

66+
private readonly textChangeListener = () => {
67+
this.recomputeAriaContext();
68+
};
6669
/**
6770
* @param workspace The workspace this bubble belongs to.
6871
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -85,6 +88,7 @@ export class TextInputBubble extends Bubble {
8588
this.contentContainer.appendChild(this.editor.getDom());
8689
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
8790
this.setSize(this.DEFAULT_SIZE, true);
91+
this.addTextChangeListener(this.textChangeListener);
8892
}
8993

9094
/** @returns the text of this bubble. */
@@ -287,6 +291,14 @@ export class TextInputBubble extends Bubble {
287291
performAction() {
288292
getFocusManager().focusNode(this.getEditor());
289293
}
294+
295+
/**
296+
* Dispose of this bubble.
297+
*/
298+
dispose() {
299+
super.dispose();
300+
this.editor.removeTextChangeListener(this.textChangeListener);
301+
}
290302
}
291303

292304
Css.register(`

packages/blockly/core/icons/comment_icon.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {EventType} from '../events/type.js';
1313
import * as eventUtils from '../events/utils.js';
1414
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
1515
import type {ISerializable} from '../interfaces/i_serializable.js';
16+
import {Msg} from '../msg.js';
1617
import * as renderManagement from '../render_management.js';
1718
import {Coordinate} from '../utils.js';
1819
import * as dom from '../utils/dom.js';
@@ -336,6 +337,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
336337
'comment',
337338
),
338339
);
340+
if (this.svgRoot) {
341+
this.recomputeAriaContext();
342+
}
339343
}
340344

341345
/** See IHasBubble.getBubble. */
@@ -376,6 +380,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
376380
this.textInputBubble.addLocationChangeListener(() =>
377381
this.onBubbleLocationChange(),
378382
);
383+
this.textInputBubble.setAriaLabelProvider(() =>
384+
Msg['BUBBLE_LABEL_COMMENT'].replace('%1', this.getText()),
385+
);
379386
}
380387

381388
/** Hides any open bubbles owned by this comment. */
@@ -403,6 +410,19 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
403410
private getBubbleOwnerRect(): Rect {
404411
return (this.sourceBlock as BlockSvg).getBoundingRectangleWithoutChildren();
405412
}
413+
414+
/**
415+
* Returns the ARIA label to use for this icon (defaults to null). Note that this
416+
* method will only be called during initialization by default, so dynamic changes
417+
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
418+
*
419+
* @returns The ARIA label to use for this icon, or null to use a default.
420+
*/
421+
protected override getAriaLabel(): string | null {
422+
return this.bubbleIsVisible()
423+
? Msg['ICON_LABEL_COMMENT_OPEN']
424+
: Msg['ICON_LABEL_COMMENT_CLOSED'];
425+
}
406426
}
407427

408428
/** The save state format for a comment icon. */

packages/blockly/core/icons/icon.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import type {IContextMenu} from '../interfaces/i_contextmenu.js';
1212
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
1313
import {hasBubble} from '../interfaces/i_has_bubble.js';
1414
import type {IIcon} from '../interfaces/i_icon.js';
15+
import {Msg} from '../msg.js';
1516
import * as renderManagement from '../render_management.js';
1617
import * as tooltip from '../tooltip.js';
18+
import {aria} from '../utils.js';
1719
import {Coordinate} from '../utils/coordinate.js';
1820
import * as dom from '../utils/dom.js';
1921
import * as idGenerator from '../utils/idgenerator.js';
@@ -75,6 +77,7 @@ export abstract class Icon implements IIcon, IContextMenu {
7577
);
7678
(this.svgRoot as any).tooltip = this;
7779
tooltip.bindMouseEvents(this.svgRoot);
80+
this.recomputeAriaContext();
7881
}
7982

8083
dispose(): void {
@@ -219,4 +222,28 @@ export abstract class Icon implements IIcon, IContextMenu {
219222
showContextMenu(e: PointerEvent) {
220223
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
221224
}
225+
226+
/**
227+
* Recomputes the ARIA label and role for this icon. This is automatically called
228+
* during initialization, but implementations may find it useful to call this if
229+
* the icon's label should be changed.
230+
*/
231+
protected recomputeAriaContext(): void {
232+
const element = this.getFocusableElement();
233+
if (!element) return;
234+
aria.setRole(element, aria.Role.BUTTON);
235+
const label = this.getAriaLabel() ?? Msg['ICON_LABEL_DEFAULT'];
236+
aria.setState(element, aria.State.LABEL, label);
237+
}
238+
239+
/**
240+
* Returns the ARIA label to use for this icon (defaults to null). Note that this
241+
* method will only be called during initialization by default, so dynamic changes
242+
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
243+
*
244+
* @returns The ARIA label to use for this icon, or null to use a default.
245+
*/
246+
protected getAriaLabel(): string | null {
247+
return null;
248+
}
222249
}

packages/blockly/core/icons/mutator_icon.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {isBlockChange, isBlockCreate} from '../events/predicates.js';
1515
import {EventType} from '../events/type.js';
1616
import * as eventUtils from '../events/utils.js';
1717
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
18+
import {Msg} from '../msg.js';
1819
import * as renderManagement from '../render_management.js';
1920
import {Coordinate} from '../utils/coordinate.js';
2021
import * as dom from '../utils/dom.js';
@@ -184,6 +185,9 @@ export class MutatorIcon extends Icon implements IHasBubble {
184185
this.miniWorkspaceBubble?.addWorkspaceChangeListener(
185186
this.createMiniWorkspaceChangeListener(),
186187
);
188+
this.miniWorkspaceBubble.setAriaLabelProvider(
189+
Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'],
190+
);
187191
} else {
188192
this.miniWorkspaceBubble?.dispose();
189193
this.miniWorkspaceBubble = null;
@@ -202,6 +206,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
202206
'mutator',
203207
),
204208
);
209+
this.recomputeAriaContext();
205210
}
206211

207212
/** See IHasBubble.getBubble. */
@@ -358,4 +363,17 @@ export class MutatorIcon extends Icon implements IHasBubble {
358363
getWorkspace(): WorkspaceSvg | undefined {
359364
return this.miniWorkspaceBubble?.getWorkspace();
360365
}
366+
367+
/**
368+
* Returns the ARIA label to use for this icon (defaults to null). Note that this
369+
* method will only be called during initialization by default, so dynamic changes
370+
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
371+
*
372+
* @returns The ARIA label to use for this icon, or null to use a default.
373+
*/
374+
protected override getAriaLabel(): string | null {
375+
return this.bubbleIsVisible()
376+
? Msg['ICON_LABEL_MUTATOR_OPEN']
377+
: Msg['ICON_LABEL_MUTATOR_CLOSED'];
378+
}
361379
}

packages/blockly/core/icons/warning_icon.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {EventType} from '../events/type.js';
1212
import * as eventUtils from '../events/utils.js';
1313
import type {IBubble} from '../interfaces/i_bubble.js';
1414
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
15+
import {Msg} from '../msg.js';
1516
import * as renderManagement from '../render_management.js';
1617
import {Size} from '../utils.js';
1718
import {Coordinate} from '../utils/coordinate.js';
@@ -185,6 +186,9 @@ export class WarningIcon extends Icon implements IHasBubble {
185186
this,
186187
);
187188
this.applyColour();
189+
this.textBubble.setAriaLabelProvider(() =>
190+
Msg['BUBBLE_LABEL_WARNING'].replace('%1', this.getText()),
191+
);
188192
} else {
189193
this.textBubble?.dispose();
190194
this.textBubble = null;
@@ -197,6 +201,7 @@ export class WarningIcon extends Icon implements IHasBubble {
197201
'warning',
198202
),
199203
);
204+
this.recomputeAriaContext();
200205
}
201206

202207
/** See IHasBubble.getBubble. */
@@ -224,4 +229,17 @@ export class WarningIcon extends Icon implements IHasBubble {
224229
const bbox = this.sourceBlock.getSvgRoot().getBBox();
225230
return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width);
226231
}
232+
233+
/**
234+
* Returns the ARIA label to use for this icon (defaults to null). Note that this
235+
* method will only be called during initialization by default, so dynamic changes
236+
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
237+
*
238+
* @returns The ARIA label to use for this icon, or null to use a default.
239+
*/
240+
protected override getAriaLabel(): string | null {
241+
return this.bubbleIsVisible()
242+
? Msg['ICON_LABEL_WARNING_OPEN']
243+
: Msg['ICON_LABEL_WARNING_CLOSED'];
244+
}
227245
}

packages/blockly/msg/json/en.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"@metadata": {
33
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
4-
"lastupdated": "2026-04-30 15:41:41.211465",
4+
"lastupdated": "2026-05-01 14:09:40.345417",
55
"locale": "en",
66
"messagedocumentation" : "qqq"
77
},
@@ -504,5 +504,14 @@
504504
"FIELD_LABEL_VARIABLE": "Variable '%1'",
505505
"ARIA_LABEL_BUTTON": "button",
506506
"ARIA_LABEL_HEADING": "heading",
507-
"BUBBLE_LABEL_DEFAULT": "Bubble"
507+
"BUBBLE_LABEL_DEFAULT": "Bubble",
508+
"BUBBLE_LABEL_COMMENT": "Comment: %1",
509+
"BUBBLE_LABEL_WARNING": "Warning: %1",
510+
"ICON_LABEL_DEFAULT": "Icon",
511+
"ICON_LABEL_COMMENT_CLOSED": "Open Comment",
512+
"ICON_LABEL_COMMENT_OPEN": "Close Comment",
513+
"ICON_LABEL_MUTATOR_CLOSED": "Edit this block",
514+
"ICON_LABEL_MUTATOR_OPEN": "Close block editor",
515+
"ICON_LABEL_WARNING_CLOSED": "Open Warning",
516+
"ICON_LABEL_WARNING_OPEN": "Close Warning"
508517
}

packages/blockly/msg/json/qqq.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,5 +512,14 @@
512512
"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''",
513513
"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.",
514514
"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.",
515-
"BUBBLE_LABEL_DEFAULT": "Default label for bubbles. This is only used if a bubble is created without a label provider."
515+
"BUBBLE_LABEL_DEFAULT": "Default label for bubbles. This is only used if a bubble is created without a label provider.",
516+
"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.'",
517+
"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.'",
518+
"ICON_LABEL_DEFAULT": "Label for an icon, used by screen readers to identify it.",
519+
"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.",
520+
"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.",
521+
"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.",
522+
"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.",
523+
"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.",
524+
"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."
516525
}

packages/blockly/msg/messages.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,3 +2007,34 @@ Blockly.Msg.ARIA_LABEL_HEADING = 'heading';
20072007
/** @type {string} */
20082008
/// Default label for bubbles. This is only used if a bubble is created without a label provider.
20092009
Blockly.Msg.BUBBLE_LABEL_DEFAULT = 'Bubble';
2010+
/** @type {string} */
2011+
/// Label for a comment bubble. Placeholder corresponds to the content of the comment.
2012+
/// \n\nParameters:\n* %1 - the content of the comment
2013+
/// \n\nExamples:\n* "Comment: This block does something important."
2014+
Blockly.Msg.BUBBLE_LABEL_COMMENT = 'Comment: %1';
2015+
/** @type {string} */
2016+
/// Label for a warning bubble. Placeholder corresponds to the content of the warning.
2017+
/// \n\nParameters:\n* %1 - the content of the warning
2018+
/// \n\nExamples:\n* "Warning: Something went wrong with this block."
2019+
Blockly.Msg.BUBBLE_LABEL_WARNING = 'Warning: %1';
2020+
/** @type {string} */
2021+
/// Label for an icon, used by screen readers to identify it.
2022+
Blockly.Msg.ICON_LABEL_DEFAULT = 'Icon';
2023+
/** @type {string} */
2024+
/// 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.
2025+
Blockly.Msg.ICON_LABEL_COMMENT_CLOSED = 'Open Comment';
2026+
/** @type {string} */
2027+
/// Label for an icon, used by screen readers to identify an open comment. Clicking on the icon closes the comment's bubble.
2028+
Blockly.Msg.ICON_LABEL_COMMENT_OPEN = 'Close Comment';
2029+
/** @type {string} */
2030+
/// 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.
2031+
Blockly.Msg.ICON_LABEL_MUTATOR_CLOSED = 'Edit this block';
2032+
/** @type {string} */
2033+
/// Label for an icon, used by screen readers to identify an open mutator. Clicking on the icon closes the mutator's bubble.
2034+
Blockly.Msg.ICON_LABEL_MUTATOR_OPEN = 'Close block editor';
2035+
/** @type {string} */
2036+
/// 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.
2037+
Blockly.Msg.ICON_LABEL_WARNING_CLOSED = 'Open Warning';
2038+
/** @type {string} */
2039+
/// Label for an icon, used by screen readers to identify an open warning. Clicking on the icon closes the warning's bubble.
2040+
Blockly.Msg.ICON_LABEL_WARNING_OPEN = 'Close Warning';

packages/blockly/tests/mocha/block_test.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,26 +1897,56 @@ suite('Blocks', function () {
18971897

18981898
suite('ARIA', function () {
18991899
setup(async function () {
1900-
this.block.setWarningText('Warning Text');
1900+
this.block.setWarningText('Something went wrong');
19011901
this.block.initSvg();
19021902
this.block.render();
1903-
const icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
1904-
icon.performAction();
1905-
await Blockly.renderManagement.finishQueuedRenders();
1903+
this.icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
1904+
await this.icon.setBubbleVisible(true);
19061905

1907-
this.bubble = icon.getBubble();
1906+
this.bubble = this.icon.getBubble();
19081907
});
1908+
function getFocusableAriaLabel(iFocusable) {
1909+
return iFocusable.getFocusableElement().getAttribute('aria-label');
1910+
}
19091911
test('Bubble has ARIA label', async function () {
19101912
assert.isTrue(
19111913
this.bubble.focusableElement.hasAttribute('aria-label'),
19121914
);
19131915
});
1916+
test('Bubble has working ARIA label provider', function () {
1917+
const label = getFocusableAriaLabel(this.bubble);
1918+
assert.include(label, 'Warning');
1919+
assert.include(label, 'Something went wrong');
1920+
});
19141921
test('Bubble has ARIA role of group', async function () {
19151922
assert.equal(
19161923
this.bubble.focusableElement.getAttribute('role'),
19171924
'group',
19181925
);
19191926
});
1927+
test('Bubble uses function provider ARIA label when provided', function () {
1928+
this.bubble.setAriaLabelProvider(() => 'Custom warning label');
1929+
const label = getFocusableAriaLabel(this.bubble);
1930+
assert.equal(label, 'Custom warning label');
1931+
});
1932+
test('Bubble uses string provider ARIA label when provided', function () {
1933+
this.bubble.setAriaLabelProvider('Custom warning label');
1934+
const label = getFocusableAriaLabel(this.bubble);
1935+
assert.equal(label, 'Custom warning label');
1936+
});
1937+
test('Mutator icon label changes when bubble is opened', async function () {
1938+
const openLabel = getFocusableAriaLabel(this.icon);
1939+
assert.equal(openLabel, 'Close Warning');
1940+
await this.icon.setBubbleVisible(false);
1941+
1942+
const closedLabel = getFocusableAriaLabel(this.icon);
1943+
assert.equal(closedLabel, 'Open Warning');
1944+
});
1945+
test('Bubble uses default ARIA label when no provider is set', function () {
1946+
this.bubble.setAriaLabelProvider(null);
1947+
const label = getFocusableAriaLabel(this.bubble);
1948+
assert.equal(label, 'Bubble');
1949+
});
19201950
});
19211951
});
19221952

0 commit comments

Comments
 (0)