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
This commit is contained in:
Michael Harvey
2026-04-30 16:26:14 -04:00
committed by GitHub
parent 0bea583e55
commit 9a01417400
7 changed files with 155 additions and 6 deletions
+70 -1
View File
@@ -16,6 +16,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.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';
@@ -25,6 +26,13 @@ import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
* Represents a either a string or a function that, when called, can provide a
* custom ARIA string to represent a bubble, or null if the default fallback
* should be used. See setAriaLabelProvider for more context.
*/
export type AriaLabelProvider = string | ((bubble: Bubble) => string | null);
/**
* The abstract pop-up bubble class. This creates a UI that looks like a speech
* bubble, where it has a "tail" that points to the block, and a "head" that
@@ -91,10 +99,15 @@ export abstract class Bubble
/** The position of the left of the bubble realtive to its anchor. */
private relativeLeft = 0;
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
private dragStrategy: BubbleDragStrategy = new BubbleDragStrategy(
this,
this.workspace,
);
private focusableElement: SVGElement | HTMLElement;
private ariaLabelProvider: AriaLabelProvider | null = null;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
@@ -159,6 +172,7 @@ export abstract class Bubble
this,
this.onKeyDown,
);
this.recomputeAriaContext();
}
/** Dispose of this bubble. */
@@ -759,4 +773,59 @@ export abstract class Bubble
getOwner(): (IHasBubble & IFocusableNode) | undefined {
return this.owner;
}
/**
* Recomputes the ARIA label and role for this bubble. This is automatically called
* during initialization, but implementations may find it useful to call this if
* the bubble's label should be changed.
*
* Bubbles use a default non-specific label unless they're customized otherwise
* which is the responsibility of the bubble's owner rather than bubble
* implementations. Customization can be done via setAriaLabelProvider.
*/
protected recomputeAriaContext(): void {
const element = this.getFocusableElement();
if (!element) return;
aria.setRole(element, aria.Role.GROUP);
const label = this.getAriaLabel()?.trim();
aria.setState(element, aria.State.LABEL, label ? label : 'Bubble');
}
/**
* Sets a custom ARIA label provider for this bubble, or null if it should be reset
* to use the default method.
*
* Bubbles do not compute ARIA labels specifically to their implementation since
* they can be rather general-purpose. Instead, owners of the specific bubble
* instance (such as an icon) are responsible for defining custom label providers
* for their bubbles.
*
* Note that calling this isn't sufficient for it to actually be used.
* recomputeAriaContext will likely also need to be called to actually apply the
* custom label to the bubble's focusable element.
*/
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
this.ariaLabelProvider = provider;
}
/**
* Returns the ARIA label to use for this bubble based on the provider set via
* setAriaLabelProvider. This will return null if the provider is absent or
* returns null.
*
* @returns The ARIA label to use for this bubble, or null if one is not provided.
*/
getAriaLabel(): string | null {
if (this.ariaLabelProvider) {
if (typeof this.ariaLabelProvider === 'string') {
return this.ariaLabelProvider;
} else if (typeof this.ariaLabelProvider === 'function') {
return this.ariaLabelProvider(this);
}
}
return null;
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-29 16:09:43.926632",
"lastupdated": "2026-04-30 15:41:41.211465",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -494,14 +494,15 @@
"ARIA_TYPE_FIELD_IMAGE": "image",
"ARIA_TYPE_FIELD_CHECKBOX": "checkbox",
"FIELD_LABEL_EDIT_PREFIX": "Edit %1",
"FIELD_LABEL_OPTION_INDEX": "Option %1",
"OPEN_TRASH": "Open trash",
"ZOOM_IN": "Zoom in",
"ZOOM_OUT": "Zoom out",
"RESET_ZOOM": "Reset zoom",
"FIELD_LABEL_OPTION_INDEX": "Option %1",
"FIELD_LABEL_CHECKBOX_CHECKED": "Checked",
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked",
"FIELD_LABEL_VARIABLE": "Variable '%1'",
"ARIA_LABEL_BUTTON": "button",
"ARIA_LABEL_HEADING": "heading"
"ARIA_LABEL_HEADING": "heading",
"BUBBLE_LABEL_DEFAULT": "Bubble"
}
+3 -2
View File
@@ -502,14 +502,15 @@
"ARIA_TYPE_FIELD_IMAGE": "ARIA type name of an image field, used by screen readers to identify the type of field.",
"ARIA_TYPE_FIELD_CHECKBOX": "ARIA type name of an checkbox field, used by screen readers to identify the type of field.",
"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'",
"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'",
"OPEN_TRASH": "ARIA label for the trashcan.",
"ZOOM_IN": "ARIA label for the zoom in button.",
"ZOOM_OUT": "ARIA label for the zoom out button.",
"RESET_ZOOM": "ARIA label for the reset zoom button.",
"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'",
"FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.",
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.",
"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."
"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."
}
+3
View File
@@ -2004,3 +2004,6 @@ Blockly.Msg.ARIA_LABEL_BUTTON = 'button';
/// 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.
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';
@@ -1894,6 +1894,30 @@ suite('Blocks', function () {
'Expected warning icon to be deleted after all warning text is cleared',
);
});
suite('ARIA', function () {
setup(async function () {
this.block.setWarningText('Warning Text');
this.block.initSvg();
this.block.render();
const icon = this.block.getIcon(Blockly.icons.WarningIcon.TYPE);
icon.performAction();
await Blockly.renderManagement.finishQueuedRenders();
this.bubble = icon.getBubble();
});
test('Bubble has ARIA label', async function () {
assert.isTrue(
this.bubble.focusableElement.hasAttribute('aria-label'),
);
});
test('Bubble has ARIA role of group', async function () {
assert.equal(
this.bubble.focusableElement.getAttribute('role'),
'group',
);
});
});
});
suite('Warning icons and collapsing', function () {
@@ -212,4 +212,35 @@ suite('Comments', function () {
assert.equal(block.getCommentText(), 'hey there');
});
});
suite('ARIA', 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();
});
test('Bubble has ARIA label', function () {
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
});
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',
);
});
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',
);
});
});
});
@@ -84,4 +84,24 @@ suite('Mutator', function () {
);
});
});
suite('ARIA', 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();
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('Bubble has ARIA label', async function () {
assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label'));
});
test('Bubble has ARIA role of group', async function () {
assert.equal(this.bubble.focusableElement.getAttribute('role'), 'group');
});
});
});