diff --git a/packages/blockly/core/comments/collapse_comment_bar_button.ts b/packages/blockly/core/comments/collapse_comment_bar_button.ts index 304e2af8125..61b4f7b60f9 100644 --- a/packages/blockly/core/comments/collapse_comment_bar_button.ts +++ b/packages/blockly/core/comments/collapse_comment_bar_button.ts @@ -5,6 +5,7 @@ */ import * as browserEvents from '../browser_events.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; @@ -56,6 +57,7 @@ export class CollapseCommentBarButton extends CommentBarButton { }, this.container, ); + this.recomputeAriaContext(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -95,8 +97,22 @@ export class CollapseCommentBarButton extends CommentBarButton { } this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed()); + this.recomputeAriaContext(); this.workspace.hideChaff(); e?.stopPropagation(); } + + /** + * Returns the ARIA label to use for this button (defaults to null). Note that this + * method will only be called and apply when recomputeAriaContext is called. + * + * @returns The ARIA label to use for this button, or null to use a default. + */ + protected getAriaLabel(): string { + const isCollapsed = this.getCommentView().isCollapsed(); + return isCollapsed + ? Msg['ARIA_LABEL_COMMENT_EXPAND'] + : Msg['ARIA_LABEL_COMMENT_COLLAPSE']; + } } diff --git a/packages/blockly/core/comments/comment_bar_button.ts b/packages/blockly/core/comments/comment_bar_button.ts index be130b0e335..c4d10b975e3 100644 --- a/packages/blockly/core/comments/comment_bar_button.ts +++ b/packages/blockly/core/comments/comment_bar_button.ts @@ -5,6 +5,8 @@ */ import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {Msg} from '../msg.js'; +import * as aria from '../utils/aria.js'; import {Rect} from '../utils/rect.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import type {CommentView} from './comment_view.js'; @@ -102,4 +104,33 @@ export abstract class CommentBarButton implements IFocusableNode { canBeFocused() { return this.isVisible(); } + + /** + * Recomputes the ARIA label and role for this button. Note that this is not + * automatically called during initialization and must be called once a button's + * focusable element (icon) is initialized. Implementations may also find it useful + * to call this if the button's label should be changed. + */ + protected recomputeAriaContext(): void { + if (!this.icon) return; + + aria.setRole(this.icon, aria.Role.BUTTON); + + const label = this.getAriaLabel(); + aria.setState( + this.icon, + aria.State.LABEL, + label || Msg['ARIA_LABEL_BUTTON'], + ); + } + + /** + * Returns the ARIA label to use for this button (defaults to null). Note that this + * method will only be called and apply when recomputeAriaContext is called. + * + * @returns The ARIA label to use for this button, or null to use a default. + */ + protected getAriaLabel(): string | null { + return null; + } } diff --git a/packages/blockly/core/comments/comment_editor.ts b/packages/blockly/core/comments/comment_editor.ts index 5d41a7c3866..6d03cc9c08c 100644 --- a/packages/blockly/core/comments/comment_editor.ts +++ b/packages/blockly/core/comments/comment_editor.ts @@ -4,18 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {BlockSvg} from '../block_svg.js'; import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {Msg} from '../msg.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import * as svgMath from '../utils/svg_math.js'; import {WorkspaceSvg} from '../workspace_svg.js'; +import {RenderedWorkspaceComment} from './rendered_workspace_comment.js'; /** * String added to the ID of a workspace comment to identify @@ -40,6 +43,9 @@ export class CommentEditor implements IFocusableNode { /** The current text of the comment. Updates on text area change. */ private text: string = ''; + /** The parent object that owns this comment editor. */ + private parent?: BlockSvg | RenderedWorkspaceComment; + constructor( public workspace: WorkspaceSvg, commentId: string, @@ -57,6 +63,7 @@ export class CommentEditor implements IFocusableNode { ) as HTMLTextAreaElement; this.textArea.setAttribute('tabindex', '-1'); this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR'); + aria.setRole(this.textArea, aria.Role.TEXTBOX); this.textArea.setAttribute( 'placeholder', Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'], @@ -129,6 +136,23 @@ export class CommentEditor implements IFocusableNode { this.onTextChange(); } + /** + * Sets the parent object that owns this comment editor. + * + * @param newParent The parent of this comment editor. + * @internal + */ + setParent(newParent: BlockSvg | RenderedWorkspaceComment): void { + this.parent = newParent; + } + + /** + * Returns the parent object that owns this comment editor, if any. + */ + getParent(): BlockSvg | RenderedWorkspaceComment | undefined { + return this.parent; + } + /** * Triggers listeners when the text of the comment changes, either * programmatically or manually by the user. diff --git a/packages/blockly/core/comments/comment_view.ts b/packages/blockly/core/comments/comment_view.ts index b1cd628f8dd..dec6df2af5e 100644 --- a/packages/blockly/core/comments/comment_view.ts +++ b/packages/blockly/core/comments/comment_view.ts @@ -6,10 +6,11 @@ import * as browserEvents from '../browser_events.js'; import * as css from '../css.js'; -import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as drag from '../utils/drag.js'; @@ -107,6 +108,12 @@ export class CommentView implements IRenderedElement { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); + aria.setRole(this.svgRoot, aria.Role.BUTTON); + aria.setState( + this.svgRoot, + aria.State.ROLEDESCRIPTION, + Msg['ARIA_LABEL_COMMENT'], + ); this.highlightRect = this.createHighlightRect(this.svgRoot); @@ -120,6 +127,11 @@ export class CommentView implements IRenderedElement { } = this.createTopBar(this.svgRoot)); this.commentEditor = this.createTextArea(); + aria.setState( + this.svgRoot, + aria.State.LABELLEDBY, + this.commentEditor.getFocusableElement().id, + ); this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); @@ -235,7 +247,7 @@ export class CommentView implements IRenderedElement { * * @returns The FocusableNode representing the editor portion of this comment. */ - getEditorFocusableNode(): IFocusableNode { + getEditorFocusableNode(): CommentEditor { return this.commentEditor; } diff --git a/packages/blockly/core/comments/delete_comment_bar_button.ts b/packages/blockly/core/comments/delete_comment_bar_button.ts index 15ed4b2598b..6f470455f0d 100644 --- a/packages/blockly/core/comments/delete_comment_bar_button.ts +++ b/packages/blockly/core/comments/delete_comment_bar_button.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; +import {Msg} from '../msg.js'; import * as touch from '../touch.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; @@ -56,6 +57,7 @@ export class DeleteCommentBarButton extends CommentBarButton { }, container, ); + this.recomputeAriaContext(); this.bindId = browserEvents.conditionalBind( this.icon, 'pointerdown', @@ -104,4 +106,14 @@ export class DeleteCommentBarButton extends CommentBarButton { getFocusManager().focusNode(this.workspace); this.workspace.getAudioManager().play('delete'); } + + /** + * Returns the ARIA label to use for this button (defaults to null). Note that this + * method will only be called and apply when recomputeAriaContext is called. + * + * @returns The ARIA label to use for this button, or null to use a default. + */ + protected getAriaLabel(): string { + return Msg['REMOVE_COMMENT']; + } } diff --git a/packages/blockly/core/comments/rendered_workspace_comment.ts b/packages/blockly/core/comments/rendered_workspace_comment.ts index 5edafd5fe8a..f60e5bae865 100644 --- a/packages/blockly/core/comments/rendered_workspace_comment.ts +++ b/packages/blockly/core/comments/rendered_workspace_comment.ts @@ -59,6 +59,7 @@ export class RenderedWorkspaceComment this.workspace = workspace; this.view = new CommentView(workspace, this.id); + this.view.getEditorFocusableNode().setParent(this); // Set the size to the default size as defined in the superclass. this.view.setSize(this.getSize()); this.view.setEditable(this.isEditable()); diff --git a/packages/blockly/core/icons/comment_icon.ts b/packages/blockly/core/icons/comment_icon.ts index eb485b062a2..a60bad070a7 100644 --- a/packages/blockly/core/icons/comment_icon.ts +++ b/packages/blockly/core/icons/comment_icon.ts @@ -370,6 +370,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { this.getBubbleOwnerRect(), this, ); + this.textInputBubble.getEditor().setParent(this.sourceBlock as BlockSvg); this.textInputBubble.setText(this.getText()); this.textInputBubble.setSize(this.bubbleSize, true); if (this.bubbleLocation) { diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 3513b2e8cfd..ae94255e7a3 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-05-06 14:12:59.060731", + "lastupdated": "2026-05-07 12:03:23.440539", "locale": "en", "messagedocumentation" : "qqq" }, @@ -514,5 +514,8 @@ "ICON_LABEL_MUTATOR_CLOSED": "Edit this block", "ICON_LABEL_MUTATOR_OPEN": "Close block editor", "ICON_LABEL_WARNING_CLOSED": "Open Warning", - "ICON_LABEL_WARNING_OPEN": "Close Warning" + "ICON_LABEL_WARNING_OPEN": "Close Warning", + "ARIA_LABEL_COMMENT": "Comment", + "ARIA_LABEL_COMMENT_COLLAPSE": "Collapse Comment", + "ARIA_LABEL_COMMENT_EXPAND": "Expand Comment" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 4fc7a0b53f0..49e642f935f 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -522,5 +522,8 @@ "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.", "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.", "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.", - "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." + "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.", + "ARIA_LABEL_COMMENT": "ARIA label for a comment.", + "ARIA_LABEL_COMMENT_COLLAPSE": "ARIA label for an expanded comment's collapse button.", + "ARIA_LABEL_COMMENT_EXPAND": "ARIA label for a collapsed comment's expand button." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 82ccae166ff..c592dd67981 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -2037,4 +2037,13 @@ Blockly.Msg.ICON_LABEL_MUTATOR_OPEN = 'Close block editor'; Blockly.Msg.ICON_LABEL_WARNING_CLOSED = 'Open Warning'; /** @type {string} */ /// Label for an icon, used by screen readers to identify an open warning. Clicking on the icon closes the warning's bubble. -Blockly.Msg.ICON_LABEL_WARNING_OPEN = 'Close Warning'; \ No newline at end of file +Blockly.Msg.ICON_LABEL_WARNING_OPEN = 'Close Warning'; +/** @type {string} */ +/// ARIA label for a comment. +Blockly.Msg.ARIA_LABEL_COMMENT = 'Comment'; +/** @type {string} */ +/// ARIA label for an expanded comment's collapse button. +Blockly.Msg.ARIA_LABEL_COMMENT_COLLAPSE = 'Collapse Comment'; +/** @type {string} */ +/// ARIA label for a collapsed comment's expand button. +Blockly.Msg.ARIA_LABEL_COMMENT_EXPAND = 'Expand Comment'; \ No newline at end of file diff --git a/packages/blockly/tests/mocha/comment_test.js b/packages/blockly/tests/mocha/comment_test.js index 087c7f66162..eb0f6ab9d75 100644 --- a/packages/blockly/tests/mocha/comment_test.js +++ b/packages/blockly/tests/mocha/comment_test.js @@ -223,6 +223,14 @@ suite('Comments', function () { function getFocusableAriaLabel(iFocusable) { return iFocusable.getFocusableElement().getAttribute('aria-label'); } + function getFocusableAriaRole(iFocusable) { + return iFocusable.getFocusableElement().getAttribute('role'); + } + function getFocusableAriaDescription(iFocusable) { + return iFocusable + .getFocusableElement() + .getAttribute('aria-roledescription'); + } test('Bubble has ARIA label', function () { assert.isTrue(this.bubble.focusableElement.hasAttribute('aria-label')); }); @@ -265,5 +273,85 @@ suite('Comments', function () { const updatedLabel = getFocusableAriaLabel(this.bubble); assert.include(updatedLabel, 'updated text'); }); + suite('Comment Editor', function () { + test('Has ARIA role textbox', function () { + const editor = this.bubble.editor; + assert.equal( + editor.getFocusableElement().getAttribute('role'), + 'textbox', + ); + }); + test('Parent is initialized', function () { + const editor = this.bubble.getEditor(); + assert.exists(editor.getParent()); + }); + }); + suite('Comment View', function () { + setup(function () { + // Create workspace comment to test comment view ARIA attributes, since block comments use bubbles. + this.workspaceComment = this.workspace.newComment(); + this.workspaceComment.setText('workspace comment'); + }); + test('Has ARIA role button', function () { + const view = this.workspaceComment.view; + assert.equal(view.svgRoot.getAttribute('role'), 'button'); + }); + test('Has ARIA roledescription of comment', function () { + const view = this.workspaceComment.view; + assert.equal( + view.svgRoot.getAttribute('aria-roledescription'), + 'Comment', + ); + }); + test('Comment view is labelled by comment editor', function () { + const view = this.workspaceComment.view; + const ownerId = view.commentEditor.getFocusableElement().id; + assert.equal(view.svgRoot.getAttribute('aria-labelledby'), ownerId); + }); + }); + suite('Comment Bar Buttons', function () { + setup(function () { + this.comment = this.workspace.newComment(); + this.comment.setText('test comment'); + + this.view = this.comment.view; + }); + function getButtonLabel(button) { + return button.getFocusableElement().getAttribute('aria-label'); + } + test('Buttons have ARIA role button', function () { + for (const button of this.view.getCommentBarButtons()) { + assert.equal( + button.getFocusableElement().getAttribute('role'), + 'button', + ); + } + }); + test('Delete button has correct ARIA label', function () { + assert.equal(getButtonLabel(this.view.deleteButton), 'Remove Comment'); + }); + test('Collapse button has initial ARIA label', function () { + assert.include( + getButtonLabel(this.view.foldoutButton), + 'Collapse Comment', + ); + }); + test('Collapse button updates ARIA label when toggled', function () { + const initial = getButtonLabel(this.view.foldoutButton); + assert.include(initial, 'Collapse Comment'); + + this.view.foldoutButton.performAction(); + + const updated = getButtonLabel(this.view.foldoutButton); + assert.include(updated, 'Expand Comment'); + }); + test('Buttons recompute ARIA context after creation', function () { + for (const button of this.view.getCommentBarButtons()) { + assert.isNotNull( + button.getFocusableElement().getAttribute('aria-label'), + ); + } + }); + }); }); });