Skip to content

Commit 9a01417

Browse files
authored
feat: Bubble ARIA methods (#9783)
* feat: Bubble ARIA methods * fix: lint * fix: code review * fix: whitespace * fix: use standard block * fix: remove unneeded teardown steps
1 parent 0bea583 commit 9a01417

7 files changed

Lines changed: 155 additions & 6 deletions

File tree

packages/blockly/core/bubbles/bubble.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js';
1616
import {ISelectable} from '../interfaces/i_selectable.js';
1717
import {ContainerRegion} from '../metrics_manager.js';
1818
import {Scrollbar} from '../scrollbar.js';
19+
import {aria} from '../utils.js';
1920
import {Coordinate} from '../utils/coordinate.js';
2021
import * as dom from '../utils/dom.js';
2122
import * as idGenerator from '../utils/idgenerator.js';
@@ -25,6 +26,13 @@ import {Size} from '../utils/size.js';
2526
import {Svg} from '../utils/svg.js';
2627
import {WorkspaceSvg} from '../workspace_svg.js';
2728

29+
/**
30+
* Represents a either a string or a function that, when called, can provide a
31+
* custom ARIA string to represent a bubble, or null if the default fallback
32+
* should be used. See setAriaLabelProvider for more context.
33+
*/
34+
export type AriaLabelProvider = string | ((bubble: Bubble) => string | null);
35+
2836
/**
2937
* The abstract pop-up bubble class. This creates a UI that looks like a speech
3038
* bubble, where it has a "tail" that points to the block, and a "head" that
@@ -91,10 +99,15 @@ export abstract class Bubble
9199
/** The position of the left of the bubble realtive to its anchor. */
92100
private relativeLeft = 0;
93101

94-
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
102+
private dragStrategy: BubbleDragStrategy = new BubbleDragStrategy(
103+
this,
104+
this.workspace,
105+
);
95106

96107
private focusableElement: SVGElement | HTMLElement;
97108

109+
private ariaLabelProvider: AriaLabelProvider | null = null;
110+
98111
/**
99112
* @param workspace The workspace this bubble belongs to.
100113
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -159,6 +172,7 @@ export abstract class Bubble
159172
this,
160173
this.onKeyDown,
161174
);
175+
this.recomputeAriaContext();
162176
}
163177

164178
/** Dispose of this bubble. */
@@ -759,4 +773,59 @@ export abstract class Bubble
759773
getOwner(): (IHasBubble & IFocusableNode) | undefined {
760774
return this.owner;
761775
}
776+
777+
/**
778+
* Recomputes the ARIA label and role for this bubble. This is automatically called
779+
* during initialization, but implementations may find it useful to call this if
780+
* the bubble's label should be changed.
781+
*
782+
* Bubbles use a default non-specific label unless they're customized otherwise
783+
* which is the responsibility of the bubble's owner rather than bubble
784+
* implementations. Customization can be done via setAriaLabelProvider.
785+
*/
786+
protected recomputeAriaContext(): void {
787+
const element = this.getFocusableElement();
788+
if (!element) return;
789+
790+
aria.setRole(element, aria.Role.GROUP);
791+
792+
const label = this.getAriaLabel()?.trim();
793+
794+
aria.setState(element, aria.State.LABEL, label ? label : 'Bubble');
795+
}
796+
797+
/**
798+
* Sets a custom ARIA label provider for this bubble, or null if it should be reset
799+
* to use the default method.
800+
*
801+
* Bubbles do not compute ARIA labels specifically to their implementation since
802+
* they can be rather general-purpose. Instead, owners of the specific bubble
803+
* instance (such as an icon) are responsible for defining custom label providers
804+
* for their bubbles.
805+
*
806+
* Note that calling this isn't sufficient for it to actually be used.
807+
* recomputeAriaContext will likely also need to be called to actually apply the
808+
* custom label to the bubble's focusable element.
809+
*/
810+
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
811+
this.ariaLabelProvider = provider;
812+
}
813+
814+
/**
815+
* Returns the ARIA label to use for this bubble based on the provider set via
816+
* setAriaLabelProvider. This will return null if the provider is absent or
817+
* returns null.
818+
*
819+
* @returns The ARIA label to use for this bubble, or null if one is not provided.
820+
*/
821+
getAriaLabel(): string | null {
822+
if (this.ariaLabelProvider) {
823+
if (typeof this.ariaLabelProvider === 'string') {
824+
return this.ariaLabelProvider;
825+
} else if (typeof this.ariaLabelProvider === 'function') {
826+
return this.ariaLabelProvider(this);
827+
}
828+
}
829+
return null;
830+
}
762831
}

packages/blockly/msg/json/en.json

Lines changed: 4 additions & 3 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-29 16:09:43.926632",
4+
"lastupdated": "2026-04-30 15:41:41.211465",
55
"locale": "en",
66
"messagedocumentation" : "qqq"
77
},
@@ -494,14 +494,15 @@
494494
"ARIA_TYPE_FIELD_IMAGE": "image",
495495
"ARIA_TYPE_FIELD_CHECKBOX": "checkbox",
496496
"FIELD_LABEL_EDIT_PREFIX": "Edit %1",
497-
"FIELD_LABEL_OPTION_INDEX": "Option %1",
498497
"OPEN_TRASH": "Open trash",
499498
"ZOOM_IN": "Zoom in",
500499
"ZOOM_OUT": "Zoom out",
501500
"RESET_ZOOM": "Reset zoom",
501+
"FIELD_LABEL_OPTION_INDEX": "Option %1",
502502
"FIELD_LABEL_CHECKBOX_CHECKED": "Checked",
503503
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked",
504504
"FIELD_LABEL_VARIABLE": "Variable '%1'",
505505
"ARIA_LABEL_BUTTON": "button",
506-
"ARIA_LABEL_HEADING": "heading"
506+
"ARIA_LABEL_HEADING": "heading",
507+
"BUBBLE_LABEL_DEFAULT": "Bubble"
507508
}

packages/blockly/msg/json/qqq.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,14 +502,15 @@
502502
"ARIA_TYPE_FIELD_IMAGE": "ARIA type name of an image field, used by screen readers to identify the type of field.",
503503
"ARIA_TYPE_FIELD_CHECKBOX": "ARIA type name of an checkbox field, used by screen readers to identify the type of field.",
504504
"FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'",
505-
"FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'",
506505
"OPEN_TRASH": "ARIA label for the trashcan.",
507506
"ZOOM_IN": "ARIA label for the zoom in button.",
508507
"ZOOM_OUT": "ARIA label for the zoom out button.",
509508
"RESET_ZOOM": "ARIA label for the reset zoom button.",
509+
"FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'",
510510
"FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.",
511511
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.",
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.",
514-
"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."
514+
"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."
515516
}

packages/blockly/msg/messages.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,3 +2004,6 @@ Blockly.Msg.ARIA_LABEL_BUTTON = 'button';
20042004
/// technial reasons cannot be given a role of heading. Ideally, this would match
20052005
/// the localized name for what screenreaders announce for <h1> elements in your language.
20062006
Blockly.Msg.ARIA_LABEL_HEADING = 'heading';
2007+
/** @type {string} */
2008+
/// Default label for bubbles. This is only used if a bubble is created without a label provider.
2009+
Blockly.Msg.BUBBLE_LABEL_DEFAULT = 'Bubble';

packages/blockly/tests/mocha/block_test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,30 @@ suite('Blocks', function () {
18941894
'Expected warning icon to be deleted after all warning text is cleared',
18951895
);
18961896
});
1897+
1898+
suite('ARIA', function () {
1899+
setup(async function () {
1900+
this.block.setWarningText('Warning Text');
1901+
this.block.initSvg();
1902+
this.block.render();
1903+
const icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
1904+
icon.performAction();
1905+
await Blockly.renderManagement.finishQueuedRenders();
1906+
1907+
this.bubble = icon.getBubble();
1908+
});
1909+
test('Bubble has ARIA label', async function () {
1910+
assert.isTrue(
1911+
this.bubble.focusableElement.hasAttribute('aria-label'),
1912+
);
1913+
});
1914+
test('Bubble has ARIA role of group', async function () {
1915+
assert.equal(
1916+
this.bubble.focusableElement.getAttribute('role'),
1917+
'group',
1918+
);
1919+
});
1920+
});
18971921
});
18981922

18991923
suite('Warning icons and collapsing', function () {

packages/blockly/tests/mocha/comment_test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,35 @@ suite('Comments', function () {
212212
assert.equal(block.getCommentText(), 'hey there');
213213
});
214214
});
215+
suite('ARIA', function () {
216+
setup(async function () {
217+
const block = this.workspace.newBlock('empty_block');
218+
block.setCommentText('test text');
219+
const commentIcon = block.getIcon(Blockly.icons.IconType.COMMENT);
220+
await commentIcon.setBubbleVisible(true);
221+
this.bubble = commentIcon.getBubble();
222+
});
223+
test('Bubble has ARIA label', function () {
224+
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
225+
});
226+
test('Bubble has ARIA role of group', function () {
227+
assert.equal(this.bubble.focusableElement.getAttribute('role'), 'group');
228+
});
229+
test('Bubble can use AriaLabelProvider function', function () {
230+
this.bubble.setAriaLabelProvider(() => 'comment aria label');
231+
this.bubble.recomputeAriaContext();
232+
assert.equal(
233+
this.bubble.focusableElement.getAttribute('aria-label'),
234+
'comment aria label',
235+
);
236+
});
237+
test('Bubble can use AriaLabelProvider string', function () {
238+
this.bubble.setAriaLabelProvider('comment aria label');
239+
this.bubble.recomputeAriaContext();
240+
assert.equal(
241+
this.bubble.focusableElement.getAttribute('aria-label'),
242+
'comment aria label',
243+
);
244+
});
245+
});
215246
});

packages/blockly/tests/mocha/mutator_test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,24 @@ suite('Mutator', function () {
8484
);
8585
});
8686
});
87+
suite('ARIA', function () {
88+
setup(async function () {
89+
this.workspace = Blockly.inject('blocklyDiv', {});
90+
const block = createRenderedBlock(this.workspace, 'controls_if');
91+
const icon = block.getIcon(Blockly.icons.MutatorIcon.TYPE);
92+
await icon.setBubbleVisible(true);
93+
this.bubble = icon.getBubble();
94+
});
95+
96+
teardown(function () {
97+
sharedTestTeardown.call(this);
98+
});
99+
100+
test('Bubble has ARIA label', async function () {
101+
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
102+
});
103+
test('Bubble has ARIA role of group', async function () {
104+
assert.equal(this.bubble.focusableElement.getAttribute('role'), 'group');
105+
});
106+
});
87107
});

0 commit comments

Comments
 (0)