mirror of
https://github.com/google/blockly.git
synced 2026-05-20 19:10:13 +02:00
feat: Comments ARIA (#9832)
* feat: Comments ARIA * fix: remove logging
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user