feat: Comments ARIA (#9832)

* feat: Comments ARIA

* fix: remove logging
This commit is contained in:
Michael Harvey
2026-05-08 08:34:48 -04:00
committed by GitHub
parent ce8662893c
commit 5543e8f125
11 changed files with 206 additions and 6 deletions
@@ -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'];
}
}
@@ -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;
}
}
@@ -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.
+14 -2
View File
@@ -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;
}
@@ -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'];
}
}
@@ -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());
@@ -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) {
+5 -2
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"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"
}
+4 -1
View File
@@ -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."
}
+10 -1
View File
@@ -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';
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';
@@ -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'),
);
}
});
});
});
});