diff --git a/packages/blockly/core/bubbles/bubble.ts b/packages/blockly/core/bubbles/bubble.ts index 569a6c10c..df42fa620 100644 --- a/packages/blockly/core/bubbles/bubble.ts +++ b/packages/blockly/core/bubbles/bubble.ts @@ -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; + } } diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 37091514d..1e10a4181 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "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" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index a27280113..d88945f5c 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -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