diff --git a/core/block_svg.ts b/core/block_svg.ts index 1b85d38ce6b..0f60e0b374d 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -56,6 +56,7 @@ import * as blocks from './serialization/blocks.js'; import type {BlockStyle} from './theme.js'; import * as Tooltip from './tooltip.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; @@ -168,6 +169,8 @@ export class BlockSvg /** Whether this block is currently being dragged. */ private dragging = false; + public currentConnectionCandidate: RenderedConnection | null = null; + /** * The location of the top left of this block (in workspace coordinates) * relative to either its parent block, or the workspace origin if it has no @@ -215,7 +218,69 @@ export class BlockSvg // The page-wide unique ID of this Block used for focusing. svgPath.id = idGenerator.getNextUniqueId(); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, aria.Role.TREEITEM); + svgPath.tabIndex = -1; + this.currentConnectionCandidate = null; + this.doInit_(); + + // Note: This must be done after initialization of the block's fields. + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.getFocusableElement(), + aria.State.LABEL, + this.computeAriaLabel(), + ); + } + + private computeAriaLabel(): string { + // Guess the block's aria label based on its field labels. + if (this.isShadow()) { + // TODO: Shadows may have more than one field. + // Shadow blocks are best represented directly by their field since they + // effectively operate like a field does for keyboard navigation purposes. + const field = Array.from(this.getFields())[0]; + return ( + aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? + 'Unknown?' + ); + } + + const fieldLabels = []; + for (const field of this.getFields()) { + if (field instanceof FieldLabel) { + fieldLabels.push(field.getText()); + } + } + return fieldLabels.join(' '); + } + + collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] { + // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The + // returned list needs to be relatively stable for consistency block indexes + // read out to users via screen readers. + if (surroundParent) { + // Start from the first sibling and iterate in navigation order. + const firstSibling: BlockSvg = surroundParent.getChildren(false)[0]; + const siblings: BlockSvg[] = [firstSibling]; + let nextSibling: BlockSvg | null = firstSibling; + while ((nextSibling = nextSibling.getNextBlock())) { + siblings.push(nextSibling); + } + return siblings; + } else { + // For top-level blocks, simply return those from the workspace. + return this.workspace.getTopBlocks(false); + } + } + + computeLevelInWorkspace(): number { + const surroundParent = this.getSurroundParent(); + return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0; } /** @@ -266,12 +331,14 @@ export class BlockSvg select() { this.addSelect(); common.fireSelectedEvent(this); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, true); } /** Unselects this block. Unhighlights the block visually. */ unselect() { this.removeSelect(); common.fireSelectedEvent(null); + aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); } /** @@ -342,6 +409,8 @@ export class BlockSvg } this.applyColour(); + + this.workspace.recomputeAriaTree(); } /** @@ -1773,21 +1842,32 @@ export class BlockSvg /** Starts a drag on the block. */ startDrag(e?: PointerEvent): void { this.dragStrategy.startDrag(e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false); } /** Drags the block to the given location. */ drag(newLoc: Coordinate, e?: PointerEvent): void { this.dragStrategy.drag(newLoc, e); + const dragStrategy = this.dragStrategy as BlockDragStrategy; + const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; + this.currentConnectionCandidate = candidate; + this.announceDynamicAriaState(true, false, newLoc); } /** Ends the drag on the block. */ endDrag(e?: PointerEvent): void { this.dragStrategy.endDrag(e); + this.currentConnectionCandidate = null; + this.announceDynamicAriaState(false, false); } /** Moves the block back to where it was at the start of a drag. */ revertDrag(): void { this.dragStrategy.revertDrag(); + this.announceDynamicAriaState(false, true); } /** @@ -1852,4 +1932,53 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** + * Announces the current dynamic state of the specified block, if any. + * + * An example of dynamic state is whether the block is currently being moved, + * and in what way. These states aren't represented through ARIA directly, so + * they need to be determined and announced using an ARIA live region + * (see aria.announceDynamicAriaState). + * + * @param isMoving Whether the specified block is currently being moved. + * @param isCanceled Whether the previous movement operation has been canceled. + * @param newLoc The new location the block is moving to (if unconstrained). + */ + private announceDynamicAriaState( + isMoving: boolean, + isCanceled: boolean, + newLoc?: Coordinate, + ) { + if (isCanceled) { + aria.announceDynamicAriaState('Canceled movement'); + return; + } + if (!isMoving) return; + if (this.currentConnectionCandidate) { + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + const surroundParent = this.currentConnectionCandidate.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) { + announcementContext.push('to', 'input'); + } else { + announcementContext.push('to', 'child'); + } + if (surroundParent) { + announcementContext.push('of', surroundParent.computeAriaLabel()); + } + + // If the block is currently being moved, announce the new block label so that the user understands where it is now. + // TODO: Figure out how much recomputeAriaTreeItemDetailsRecursively needs to anticipate position if it won't be reannounced, and how much of that context should be included in the liveannouncement. + aria.announceDynamicAriaState(announcementContext.join(' ')); + } else if (newLoc) { + // The block is being freely dragged. + aria.announceDynamicAriaState( + `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`, + ); + } + } } diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index c42e602544e..4ff50c9d750 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -15,6 +15,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 * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -142,6 +143,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { this.focusableElement = overriddenFocusableElement ?? this.svgRoot; this.focusableElement.setAttribute('id', this.id); + aria.setRole(this.focusableElement, aria.Role.GROUP); + aria.setState(this.focusableElement, aria.State.LABEL, 'Bubble'); browserEvents.conditionalBind( this.background, diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts index b0738d70705..524f6e4c922 100644 --- a/core/comments/collapse_comment_bar_button.ts +++ b/core/comments/collapse_comment_bar_button.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -69,6 +70,11 @@ export class CollapseCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts index d78a7fd86a1..5cbebe74ae8 100644 --- a/core/comments/comment_bar_button.ts +++ b/core/comments/comment_bar_button.ts @@ -52,6 +52,8 @@ export abstract class CommentBarButton implements IFocusableNode { return comment; } + abstract initAria(): void; + /** Adjusts the position of this button within its parent container. */ abstract reposition(): void; diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index ac1559c4b3d..9ba0d04f9eb 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -9,6 +9,7 @@ import {getFocusManager} from '../focus_manager.js'; import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; @@ -54,6 +55,8 @@ 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); + aria.setState(this.textArea, aria.State.LABEL, 'DoNotDefine?'); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 936d746508f..782c79e49e3 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -10,6 +10,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.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'; @@ -108,6 +109,9 @@ export class CommentView implements IRenderedElement { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); + aria.setRole(this.svgRoot, aria.Role.TEXTBOX); + aria.setState(this.svgRoot, aria.State.LABEL, 'DoNotOverride?'); + this.highlightRect = this.createHighlightRect(this.svgRoot); ({ diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts index 0b7dcd0ea27..508646c566b 100644 --- a/core/comments/delete_comment_bar_button.ts +++ b/core/comments/delete_comment_bar_button.ts @@ -7,6 +7,7 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; import * as touch from '../touch.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -69,6 +70,11 @@ export class DeleteCommentBarButton extends CommentBarButton { browserEvents.unbind(this.bindId); } + override initAria(): void { + aria.setRole(this.icon, aria.Role.BUTTON); + aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?'); + } + /** * Adjusts the positioning of this button within its container. */ diff --git a/core/css.ts b/core/css.ts index 30ee47fc58a..15b6fe1b6ff 100644 --- a/core/css.ts +++ b/core/css.ts @@ -507,4 +507,12 @@ input[type=number] { ) { outline: none; } + +#blocklyAriaAnnounce { + position: absolute; + left: -9999px; + width: 1px; + height: px; + overflow: hidden; +} `; diff --git a/core/dragging/block_drag_strategy.ts b/core/dragging/block_drag_strategy.ts index 76020f90b5b..620308155e3 100644 --- a/core/dragging/block_drag_strategy.ts +++ b/core/dragging/block_drag_strategy.ts @@ -50,7 +50,7 @@ export class BlockDragStrategy implements IDragStrategy { private startLoc: Coordinate | null = null; - private connectionCandidate: ConnectionCandidate | null = null; + public connectionCandidate: ConnectionCandidate | null = null; private connectionPreviewer: IConnectionPreviewer | null = null; diff --git a/core/field.ts b/core/field.ts index fdcb2d693b9..28a0e3977ad 100644 --- a/core/field.ts +++ b/core/field.ts @@ -31,6 +31,7 @@ import {ISerializable} from './interfaces/i_serializable.js'; import type {ConstantProvider} from './renderers/common/constants.js'; import type {KeyboardShortcut} from './shortcut_registry.js'; import * as Tooltip from './tooltip.js'; +import * as aria from './utils/aria.js'; import type {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; @@ -403,6 +404,7 @@ export abstract class Field } this.textContent_ = document.createTextNode(''); this.textElement_.appendChild(this.textContent_); + aria.setState(this.textElement_, aria.State.HIDDEN, true); } /** diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index 55ed42cbf4b..f7ab38ead56 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -16,6 +16,7 @@ import './events/events_block_change.js'; import {Field, FieldConfig, FieldValidator} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -111,6 +112,14 @@ export class FieldCheckbox extends Field { const textElement = this.getTextElement(); dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField'); textElement.style.display = this.value_ ? 'block' : 'none'; + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.CHECKBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Checkbox ${this.name}` : 'Checkbox', + ); } override render_() { diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 8b01ccddab1..f3badfd6e83 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -196,6 +196,14 @@ export class FieldDropdown extends Field { dom.addClass(this.fieldGroup_, 'blocklyField'); dom.addClass(this.fieldGroup_, 'blocklyDropdownField'); } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.LISTBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Item ${this.name}` : 'Item', + ); } /** diff --git a/core/field_image.ts b/core/field_image.ts index 01133c20340..e6ac13e0810 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -13,6 +13,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -157,6 +158,14 @@ export class FieldImage extends Field { if (this.clickHandler) { this.imageElement.style.cursor = 'pointer'; } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.IMAGE); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Image ${this.name}` : 'Image', + ); } override updateSize_() {} diff --git a/core/field_input.ts b/core/field_input.ts index b685309183a..539fc8a6bea 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -172,6 +172,14 @@ export abstract class FieldInput extends Field< if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyInputField'); } + + const element = this.getFocusableElement(); + aria.setRole(element, aria.Role.TEXTBOX); + aria.setState( + element, + aria.State.LABEL, + this.name ? `Text ${this.name}` : 'Text', + ); } override isFullBlockField(): boolean { diff --git a/core/field_label.ts b/core/field_label.ts index 236154cc7b1..901c21bd000 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -14,6 +14,7 @@ import {Field, FieldConfig} from './field.js'; import * as fieldRegistry from './field_registry.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -77,6 +78,12 @@ export class FieldLabel extends Field { if (this.fieldGroup_) { dom.addClass(this.fieldGroup_, 'blocklyLabelField'); } + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText()); } /** @@ -111,6 +118,13 @@ export class FieldLabel extends Field { this.class = cssClass; } + override setValue(newValue: any, fireChangeEvent?: boolean): void { + super.setValue(newValue, fireChangeEvent); + if (this.fieldGroup_) { + this.recomputeAriaLabel(); + } + } + /** * Construct a FieldLabel from a JSON arg object, * dereferencing any string table references. diff --git a/core/flyout_button.ts b/core/flyout_button.ts index c9afb8b0159..0e83706709b 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -18,6 +18,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import type {IRenderedElement} from './interfaces/i_rendered_element.js'; import {idGenerator} from './utils.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -117,6 +118,9 @@ export class FlyoutButton this.workspace.getCanvas(), ); + aria.setRole(this.svgGroup, aria.Role.BUTTON); + aria.setState(this.svgGroup, aria.State.LABEL, 'Button'); + let shadow; if (!this.isFlyoutLabel) { // Shadow rectangle (light source does not mirror in RTL). diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 8f5a82c0d15..1b2e47149e4 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import type {ISerializable} from '../interfaces/i_serializable.js'; import * as renderManagement from '../render_management.js'; import {Coordinate} from '../utils.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'; @@ -112,6 +113,18 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyCommentIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + if (this.svgRoot) { + aria.setState( + this.svgRoot, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment', + ); + } } override dispose() { @@ -336,6 +349,8 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { 'comment', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 8f8ff70fc32..eb5b56a8080 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -11,6 +11,7 @@ import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import {hasBubble} from '../interfaces/i_has_bubble.js'; import type {IIcon} from '../interfaces/i_icon.js'; import * as tooltip from '../tooltip.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -71,6 +72,9 @@ export abstract class Icon implements IIcon { ); (this.svgRoot as any).tooltip = this; tooltip.bindMouseEvents(this.svgRoot); + + aria.setRole(this.svgRoot, aria.Role.FIGURE); + aria.setState(this.svgRoot, aria.State.LABEL, 'Icon'); } dispose(): void { diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 9055a91ea8f..af32f55df1c 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -16,6 +16,7 @@ import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -119,6 +120,16 @@ export class MutatorIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyMutatorIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator', + ); } override dispose(): void { @@ -201,6 +212,8 @@ export class MutatorIcon extends Icon implements IHasBubble { 'mutator', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index f24a6a56190..5085769eec9 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -14,6 +14,7 @@ import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Size} from '../utils.js'; +import * as aria from '../utils/aria.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -92,6 +93,16 @@ export class WarningIcon extends Icon implements IHasBubble { this.svgRoot, ); dom.addClass(this.svgRoot!, 'blocklyWarningIcon'); + + this.recomputeAriaLabel(); + } + + private recomputeAriaLabel() { + aria.setState( + this.svgRoot!, + aria.State.LABEL, + this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning', + ); } override dispose() { @@ -196,6 +207,8 @@ export class WarningIcon extends Icon implements IHasBubble { 'warning', ), ); + + this.recomputeAriaLabel(); } /** See IHasBubble.getBubble. */ diff --git a/core/inject.ts b/core/inject.ts index 4217c515119..3488f669d61 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -17,6 +17,7 @@ import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -78,6 +79,12 @@ export function inject( common.globalShortcutHandler, ); + // See: https://stackoverflow.com/a/48590836 for a reference. + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + subContainer.appendChild(ariaAnnouncementSpan); + return workspace; } diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 84905eeccc2..f1cab26da33 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -25,6 +25,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; +import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -332,6 +333,8 @@ export class RenderedConnection const highlightSvg = this.findHighlightSvg(); if (highlightSvg) { highlightSvg.style.display = ''; + aria.setRole(highlightSvg, aria.Role.FIGURE); + aria.setState(highlightSvg, aria.State.LABEL, 'Open connection'); } } diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3fcd..d86a41cb613 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -30,6 +30,7 @@ import type { StaticCategoryInfo, } from '../utils/toolbox.js'; import * as toolbox from '../utils/toolbox.js'; +import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -192,6 +193,7 @@ export class ToolboxCategory aria.setRole(this.htmlDiv_, aria.Role.TREEITEM); aria.setState(this.htmlDiv_, aria.State.SELECTED, false); aria.setState(this.htmlDiv_, aria.State.LEVEL, this.level_ + 1); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); this.rowDiv_ = this.createRowContainer_(); this.rowDiv_.style.pointerEvents = 'auto'; diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 5048ff1269d..342230fdd1b 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -20,6 +20,7 @@ import * as dom from '../utils/dom.js'; import * as toolbox from '../utils/toolbox.js'; import {ToolboxCategory} from './category.js'; import {ToolboxSeparator} from './separator.js'; +import {Toolbox} from './toolbox.js'; /** * Class for a category in a toolbox that can be collapsed. @@ -132,11 +133,25 @@ export class CollapsibleToolboxCategory const subCategories = this.getChildToolboxItems(); this.subcategoriesDiv_ = this.createSubCategoriesDom_(subCategories); - aria.setRole(this.subcategoriesDiv_, aria.Role.GROUP); this.htmlDiv_!.appendChild(this.subcategoriesDiv_); this.closeIcon_(this.iconDom_); aria.setState(this.htmlDiv_ as HTMLDivElement, aria.State.EXPANDED, false); + aria.setRole(this.htmlDiv_!, aria.Role.GROUP); + + // Ensure this group has properly set children. + const selectableChildren = + this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map( + (selectable) => selectable.getFocusableElement().id, + ); + aria.setState( + this.htmlDiv_!, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); + return this.htmlDiv_!; } diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index cd5ed245a04..9c2b5e6e2d2 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -14,8 +14,10 @@ import * as Css from '../css.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import * as registry from '../registry.js'; +import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; import type * as toolbox from '../utils/toolbox.js'; +import {Toolbox} from './toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; /** @@ -63,6 +65,10 @@ export class ToolboxSeparator extends ToolboxItem { dom.addClass(container, className); } this.htmlDiv = container; + + aria.setRole(this.htmlDiv, aria.Role.SEPARATOR); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); + return container; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index f34034d3399..3b87f1ef2c2 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -153,6 +153,8 @@ export class Toolbox this.setVisible(true); this.flyout.init(workspace); + aria.setRole(this.HtmlDiv, aria.Role.TREE); + this.render(this.toolboxDef_); const themeManager = workspace.getThemeManager(); themeManager.subscribe( @@ -186,7 +188,6 @@ export class Toolbox container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); - aria.setRole(this.contentsDiv_, aria.Role.TREE); container.appendChild(this.contentsDiv_); svg.parentNode!.insertBefore(container, svg); @@ -1150,6 +1151,32 @@ export class Toolbox this.autoHide(false); } } + + /** + * Recomputes ARIA tree ownership relationships for all of this toolbox's + * categories and items. + * + * This should only be done when the toolbox's contents have changed. + */ + recomputeAriaOwners() { + const focusable = this.getFocusableElement(); + const selectableChildren = + this.getToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildElems = selectableChildren.map((selectable) => + selectable.getFocusableElement(), + ); + const focusableChildIds = focusableChildElems.map((elem) => elem.id); + aria.setState( + focusable, + aria.State.OWNS, + [...new Set(focusableChildIds)].join(' '), + ); + // Ensure children have the correct position set. + // TODO: Fix collapsible subcategories. Their groups aren't set up correctly yet, and they aren't getting a correct accounting in top-level toolbox tree. + focusableChildElems.forEach((elem, index) => + aria.setState(elem, aria.State.POSINSET, index + 1), + ); + } } /** CSS for Toolbox. See css.js for use. */ diff --git a/core/utils/aria.ts b/core/utils/aria.ts index d997b8d0af0..aa9ec3f29d6 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -17,11 +17,6 @@ const ROLE_ATTRIBUTE = 'role'; * Copied from Closure's goog.a11y.aria.Role */ export enum Role { - // ARIA role for an interactive control of tabular data. - GRID = 'grid', - - // ARIA role for a cell in a grid. - GRIDCELL = 'gridcell', // ARIA role for a group of related elements like tree item siblings. GROUP = 'group', @@ -33,16 +28,12 @@ export enum Role { // ARIA role for menu item elements. MENUITEM = 'menuitem', - // ARIA role for a checkbox box element inside a menu. - MENUITEMCHECKBOX = 'menuitemcheckbox', // ARIA role for option items that are children of combobox, listbox, menu, // radiogroup, or tree elements. OPTION = 'option', // ARIA role for ignorable cosmetic elements with no semantic significance. PRESENTATION = 'presentation', - // ARIA role for a row of cells in a grid. - ROW = 'row', // ARIA role for a tree. TREE = 'tree', @@ -54,6 +45,12 @@ export enum Role { // ARIA role for a live region providing information. STATUS = 'status', + + IMAGE = 'image', + FIGURE = 'figure', + BUTTON = 'button', + CHECKBOX = 'checkbox', + TEXTBOX = 'textbox', } /** @@ -64,10 +61,6 @@ export enum State { // ARIA property for setting the currently active descendant of an element, // for example the selected item in a list box. Value: ID of an element. ACTIVEDESCENDANT = 'activedescendant', - // ARIA property defines the total number of columns in a table, grid, or - // treegrid. - // Value: integer. - COLCOUNT = 'colcount', // ARIA state for a disabled item. Value: one of {true, false}. DISABLED = 'disabled', @@ -89,19 +82,11 @@ export enum State { // ARIA property for setting the level of an element in the hierarchy. // Value: integer. LEVEL = 'level', - // ARIA property indicating if the element is horizontal or vertical. - // Value: one of {'vertical', 'horizontal'}. - ORIENTATION = 'orientation', // ARIA property that defines an element's number of position in a list. // Value: integer. POSINSET = 'posinset', - // ARIA property defines the total number of rows in a table, grid, or - // treegrid. - // Value: integer. - ROWCOUNT = 'rowcount', - // ARIA state for setting the currently selected item in the list. // Value: one of {true, false, undefined}. SELECTED = 'selected', @@ -121,29 +106,52 @@ export enum State { // ARIA property for removing elements from the accessibility tree. // Value: one of {true, false, undefined}. HIDDEN = 'hidden', + + ROLEDESCRIPTION = 'roledescription', + OWNS = 'owns', } /** - * Sets the role of an element. + * Updates the specific role for the specified element. * - * Similar to Closure's goog.a11y.aria + * @param element The element whose ARIA role should be changed. + * @param roleName The new role for the specified element, or null if its role + * should be cleared. + */ +export function setRole(element: Element, roleName: Role | null) { + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else element.removeAttribute(ROLE_ATTRIBUTE); +} + +/** + * Returns the ARIA role of the specified element, or null if it either doesn't + * have a designated role or if that role is unknown. * - * @param element DOM node to set role of. - * @param roleName Role name. + * @param element The element from which to retrieve its ARIA role. + * @returns The ARIA role of the element, or null if undefined or unknown. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function getRole(element: Element): Role | null { + // This is an unsafe cast which is why it needs to be checked to ensure that + // it references a valid role. + const currentRoleName = element.getAttribute(ROLE_ATTRIBUTE) as Role; + if (Object.values(Role).includes(currentRoleName)) { + return currentRoleName; + } + return null; } /** - * Sets the state or property of an element. - * Copied from Closure's goog.a11y.aria + * Sets the specified ARIA state by its name and value for the specified + * element. * - * @param element DOM node where we set state. - * @param stateName State attribute being set. - * Automatically adds prefix 'aria-' to the state name if the attribute is - * not an extra attribute. - * @param value Value for the state attribute. + * Note that the type of value is not validated against the specific type of + * state being changed, so it's up to callers to ensure the correct value is + * used for the given state. + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to change. + * @param value The new value to specify for the provided state. */ export function setState( element: Element, @@ -156,3 +164,44 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +/** + * Returns a string representation of the specified state for the specified + * element, or null if it's not defined or specified. + * + * Note that an explicit set state of 'null' will return the 'null' string, not + * the value null. + * + * @param element The element whose state is being retrieved. + * @param stateName The state to retrieve. + * @returns The string representation of the requested state for the specified + * element, or null if not defined. + */ +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + return element.getAttribute(attrStateName); +} + +/** + * Softly requests that the specified text be read to the user if a screen + * reader is currently active. + * + * This relies on a centrally managed ARIA live region that should not interrupt + * existing announcements (that is, this is what's considered a polite + * announcement). + * + * Callers should use this judiciously. It's often considered bad practice to + * over announce information that can be inferred from other sources on the + * page, so this ought to only be used when certain context cannot be easily + * determined (such as dynamic states that may not have perfect ARIA + * representations or indications). + * + * @param text The text to politely read to the user. + */ +export function announceDynamicAriaState(text: string) { + const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); + if (!ariaAnnouncementSpan) { + throw new Error('Expected element with id blocklyAriaAnnounce to exist.'); + } + ariaAnnouncementSpan.innerHTML = text; +} diff --git a/core/utils/dom.ts b/core/utils/dom.ts index 4087984151c..e32cad4d604 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -6,7 +6,8 @@ // Former goog.module ID: Blockly.utils.dom -import type {Svg} from './svg.js'; +import * as aria from './aria.js'; +import {Svg} from './svg.js'; /** * Required name space for SVG elements. @@ -62,6 +63,9 @@ export function createSvgElement( if (opt_parent) { opt_parent.appendChild(e); } + if (name === Svg.SVG || name === Svg.G) { + aria.setRole(e, aria.Role.PRESENTATION); + } return e; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index af395b077e5..2292b9b5591 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -31,6 +31,7 @@ import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; import {ComponentManager} from './component_manager.js'; import {ConnectionDB} from './connection_db.js'; +import {ConnectionType} from './connection_type.js'; import * as ContextMenu from './contextmenu.js'; import { ContextMenuOption, @@ -763,13 +764,21 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); + + let ariaLabel = null; if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); + ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; + } else if (this.isFlyout) { + ariaLabel = 'Flyout'; + } else if (this.isMutator) { + ariaLabel = 'Mutator'; + } else { + // This case can happen in some test scenarios. + // TODO: Figure out when this can happen in non-test scenarios (if ever). + ariaLabel = 'Workspace'; } + aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); + aria.setRole(this.svgGroup_, aria.Role.TREE); // Note that a alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the @@ -2936,6 +2945,53 @@ export class WorkspaceSvg setNavigator(newNavigator: Navigator) { this.navigator = newNavigator; } + + recomputeAriaTree() { + // TODO: Do this efficiently (probably incrementally). + this.getTopBlocks(false).forEach((block) => + this.recomputeAriaTreeItemDetailsRecursively(block), + ); + } + + private recomputeAriaTreeItemDetailsRecursively(block: BlockSvg) { + const elem = block.getFocusableElement(); + const connection = block.currentConnectionCandidate; + let childPosition: number; + let parentsChildCount: number; + let hierarchyDepth: number; + if (connection) { + // If the block is being inserted into a new location, the position is hypothetical. + // TODO: Figure out how to deal with output connections. + let surroundParent: BlockSvg | null; + let siblingBlocks: BlockSvg[]; + if (connection.type === ConnectionType.INPUT_VALUE) { + surroundParent = connection.sourceBlock_; + siblingBlocks = block.collectSiblingBlocks(surroundParent); + // The block is being added as a child since it's input. + // TODO: Figure out how to compute the correct position. + childPosition = 0; + } else { + surroundParent = connection.sourceBlock_.getSurroundParent(); + siblingBlocks = block.collectSiblingBlocks(surroundParent); + // The block is being added after the connected block. + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 1; + } + parentsChildCount = siblingBlocks.length + 1; + hierarchyDepth = surroundParent?.computeLevelInWorkspace() ?? 0; + } else { + const surroundParent = block.getSurroundParent(); + const siblingBlocks = block.collectSiblingBlocks(surroundParent); + childPosition = siblingBlocks.indexOf(block); + parentsChildCount = siblingBlocks.length; + hierarchyDepth = block.computeLevelInWorkspace(); + } + aria.setState(elem, aria.State.POSINSET, childPosition + 1); + aria.setState(elem, aria.State.SETSIZE, parentsChildCount); + aria.setState(elem, aria.State.LEVEL, hierarchyDepth + 1); + block + .getChildren(false) + .forEach((child) => this.recomputeAriaTreeItemDetailsRecursively(child)); + } } /**