mirror of
https://github.com/google/blockly.git
synced 2026-05-02 01:50:12 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user