diff --git a/core/block_svg.ts b/core/block_svg.ts index 1b85d38ce6b..73ef88a0a77 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -55,7 +55,7 @@ import type {IPathObject} from './renderers/common/i_path_object.js'; 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 {aria, idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Rect} from './utils/rect.js'; @@ -213,9 +213,18 @@ export class BlockSvg this.svgGroup.setAttribute('data-id', this.id); // The page-wide unique ID of this Block used for focusing. + this.svgGroup.id = idGenerator.getNextUniqueId(); svgPath.id = idGenerator.getNextUniqueId(); + // TODO: Figure out how to make this work better with trying to reduce redundant announcements. + // aria.setState(svgPath, aria.State.LIVE, 'off'); + // aria.setState(svgPath, aria.State.ATOMIC, true); + aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block'); + aria.setRole(svgPath, this.getAriaRole()); + svgPath.tabIndex = -1; + this.doInit_(); + aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getAriaLabel()); } /** @@ -274,6 +283,75 @@ export class BlockSvg common.fireSelectedEvent(null); } + private getParentAriaGroup(): Element { + const surroundingParent = this.getSurroundParent(); + if (surroundingParent) { + surroundingParent.ensureAriaConnectionToParent(); + + const parentGroupElem = surroundingParent.svgGroup; + if (aria.getRole(parentGroupElem) !== aria.Role.GROUP) { + aria.setRole(parentGroupElem, aria.Role.GROUP); + // If the parent isn't already a group, set it up as one and add it to + // its parent. + BlockSvg.addAriaOwner(surroundingParent.getFocusableElement(), parentGroupElem); + } + return parentGroupElem; + } else return this.workspace.getFocusableElement(); + } + + private ensureAriaConnectionToParent() { + // TODO: This needs to be centrally managed and set up to work across all types of workspace mutations. + // TODO: Figure out if we ever need to set aria-owns for the groups for the tree items, or just the groups themselves. + // It seems that https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/ only does the latter. + BlockSvg.addAriaOwner(this.getParentAriaGroup(), this.getFocusableElement()); + } + + // TODO: Support deregistering. + private static addAriaOwner(treeItemElement: Element, groupElement: Element) { + const ariaChildren = treeItemElement.getAttribute('aria-owns')?.split(' ') ?? []; + ariaChildren.push(groupElement.id); + treeItemElement.setAttribute('aria-owns', [... new Set(ariaChildren)].join(' ')); + } + + // TODO: Do this efficiently (probably centrally). + private recomputeAriaTreeItemDetailsRecursively() { + const elem = this.getFocusableElement(); + const connection = this.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 = this.collectSiblingBlocks(surroundParent); + // The block is being added as a child since it's input. + // TODO: Figure out how to compute the correct position. + childPosition = 1; + } else { + surroundParent = connection.sourceBlock_.getSurroundParent(); + siblingBlocks = this.collectSiblingBlocks(surroundParent); + // The block is being added after the connected block. + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; + } + parentsChildCount = siblingBlocks.length + 1; + hierarchyDepth = surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 1; + } else { + const surroundParent = this.getSurroundParent(); + const siblingBlocks = this.collectSiblingBlocks(surroundParent); + childPosition = siblingBlocks.indexOf(this) + 1; + parentsChildCount = siblingBlocks.length; + hierarchyDepth = this.computeLevelInWorkspace() + 1; + } + elem.setAttribute('aria-posinset', `${childPosition}`); + elem.setAttribute('aria-setsize', `${parentsChildCount}`); + elem.setAttribute('aria-level', `${hierarchyDepth}`); + this.getChildren(false).forEach((block) => block.recomputeAriaTreeItemDetailsRecursively()); + } + /** * Sets the parent of this block to be a new block or null. * @@ -290,6 +368,35 @@ export class BlockSvg super.setParent(newParent); dom.stopTextWidthCache(); + // ATTEMPT 3 + // this.ensureAriaConnectionToParent(); + + // ATTEMPT 2 of tree item + // const surroundingParent = this.getSurroundParent(); + // if (surroundingParent) { + // const elem = surroundingParent.getFocusableElement(); + // const parentGroupElem = surroundingParent.svgGroup; + // if (aria.getRole(parentGroupElem) !== aria.Role.GROUP) { + // // If the parent isn't already a group, then it needs to be set up as + // // one. + // aria.setRole(parentGroupElem, aria.Role.GROUP); + // const grandparent = surroundingParent.getSurroundParent(); + // if (grandparent) { + // // Update the grandparent to own this group. + // } else { + // // Update the workspace to own this group since it's top-level. + // const workspaceRoot = this.workspace.getFocusableElement(); + // const ariaChildren = elem.getAttribute('aria-owns')?.split(' ') ?? []; + // ariaChildren.push(elem.id); + // elem.setAttribute('aria-owns', [... new Set(ariaChildren)].join(' ')); + // } + // } + + // const ariaChildren = workspaceRoot.getAttribute('aria-owns')?.split(' ') ?? []; + // ariaChildren.push(this.getFocusableElement().id); + // elem.setAttribute('aria-owns', [... new Set(ariaChildren)].join(' ')); + // } + const svgRoot = this.getSvgRoot(); // Bail early if workspace is clearing, or we aren't rendered. @@ -342,6 +449,9 @@ export class BlockSvg } this.applyColour(); + + // ATTEMPT 4 (full cheating without custom English gen). + this.workspace.getTopBlocks(false).forEach((block) => (block as BlockSvg).recomputeAriaTreeItemDetailsRecursively()); } /** @@ -1773,21 +1883,31 @@ export class BlockSvg /** Starts a drag on the block. */ startDrag(e?: PointerEvent): void { this.dragStrategy.startDrag(e); + this.currentConnectionCandidate = (this.dragStrategy as BlockDragStrategy).connectionCandidate?.neighbour ?? null; + this.announceDynamicAriaState(true, false); } + // TODO: Since it's event-driven, this can probably be replaced with just locals. + private currentConnectionCandidate: RenderedConnection | null = null; + /** Drags the block to the given location. */ drag(newLoc: Coordinate, e?: PointerEvent): void { this.dragStrategy.drag(newLoc, e); + this.currentConnectionCandidate = (this.dragStrategy as BlockDragStrategy).connectionCandidate?.neighbour ?? null; + 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); } /** @@ -1841,10 +1961,12 @@ export class BlockSvg /** See IFocusableNode.onNodeFocus. */ onNodeFocus(): void { this.select(); + aria.setState(this.pathObject.svgPath, aria.State.SELECTED, true); } /** See IFocusableNode.onNodeBlur. */ onNodeBlur(): void { + aria.setState(this.pathObject.svgPath, aria.State.SELECTED, false); this.unselect(); } @@ -1852,4 +1974,107 @@ export class BlockSvg canBeFocused(): boolean { return true; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.TREEITEM; + } + + private announceDynamicAriaState(isMoving: boolean, isCanceled: boolean, newLoc?: Coordinate) { + // this.recomputeAriaTreeItemDetailsRecursively(); + // const label = this.getAriaLabel(); + // aria.setState(this.getFocusableElement(), aria.State.LABEL, label); + const connection = this.currentConnectionCandidate; + const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce'); + if (!ariaAnnouncementSpan) return; + if (isCanceled) { + ariaAnnouncementSpan.innerHTML = 'Canceled movement'; + return; + } + if (!isMoving) return; + if (connection) { + // const newParentBlock = newConnection?.sourceBlock_ as BlockSvg | undefined; + // const newSurroundBlock = newParentBlock?.getSurroundParent(); + // console.log('@@@@@ drag: go after:',newParentBlock?.getAriaLabel(), 'go under:',newSurroundBlock?.getAriaLabel()); + + // TODO: Figure out general detachment. + // TODO: Figure out how to deal with output connections. + let surroundParent: BlockSvg | null = connection.sourceBlock_; + const announcementContext = []; + announcementContext.push('Moving'); // TODO: Specialize for inserting? + // NB: Old code here doesn't seem to handle parents correctly. + if (connection.type === ConnectionType.INPUT_VALUE) { + // surroundParent = connection.sourceBlock_; + announcementContext.push('to','input','of'); + } else { + // surroundParent = connection.sourceBlock_.getSurroundParent(); + announcementContext.push('to','child','of'); + } + announcementContext.push(surroundParent.getAriaLabel()); + + // if (this.workspace.getMovingBlock() === this) { + // fieldLabels.push('Moving'); + // } + + // 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. + ariaAnnouncementSpan.innerHTML = announcementContext.join(' '); + } else if (newLoc) { + // The block is being freely dragged. + ariaAnnouncementSpan.innerHTML = `Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`; + } + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + // TODO: Blocks probably need to define their aria label as part of their block definition, but + // it can be guessed based on its field labels. + + if (this.isShadow()) { + // 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 field.getAriaLabel(); + } + + // TODO: Localize this (is it even possible?). + const fieldLabels = []; + for (const field of this.getFields()) { + if (field instanceof FieldLabel) { + fieldLabels.push(field.getText()); + } + } + // const siblingBlocks = this.collectSiblingBlocks(); + // fieldLabels.push('Block'); + // fieldLabels.push(`${siblingBlocks.indexOf(this) + 1}`); + // fieldLabels.push('of'); + // fieldLabels.push(`${siblingBlocks.length}`); + // fieldLabels.push('Level'); + // fieldLabels.push(`${this.computeLevelInWorkspace() + 1}`); + return fieldLabels.join(' '); + } + + private 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); + } + } + + private computeLevelInWorkspace(): number { + const surroundParent = this.getSurroundParent(); + return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0; + } } diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index c42e602544e..fa3786c535a 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 { aria } from '../utils.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -719,6 +720,16 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode { return true; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.GROUP; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'Bubble'; + } + /** * Returns the object that owns/hosts this bubble, if any. */ diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts index d78a7fd86a1..e03a99b0d15 100644 --- a/core/comments/comment_bar_button.ts +++ b/core/comments/comment_bar_button.ts @@ -5,6 +5,7 @@ */ import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import { aria } from '../utils.js'; import {Rect} from '../utils/rect.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js'; @@ -102,4 +103,14 @@ export abstract class CommentBarButton implements IFocusableNode { canBeFocused() { return this.isVisible(); } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.BUTTON; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'DoNotDefine?'; + } } diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index ac1559c4b3d..1112f1ecf7b 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 { aria } from '../utils.js'; import * as dom from '../utils/dom.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; @@ -185,13 +186,28 @@ export class CommentEditor implements IFocusableNode { getFocusableElement(): HTMLElement | SVGElement { return this.textArea; } + getFocusableTree(): IFocusableTree { return this.workspace; } + onNodeFocus(): void {} + onNodeBlur(): void {} + canBeFocused(): boolean { if (this.id) return true; return false; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + // TODO: Probably shouldn't do this since the textarea itself should already have this role implied. + return aria.Role.TEXTBOX; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'DoNotOverride?'; + } } diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 49c75e60883..bc33e0a90bf 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -25,6 +25,7 @@ import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import {ISelectable} from '../interfaces/i_selectable.js'; import * as layers from '../layers.js'; import * as commentSerialization from '../serialization/workspace_comments.js'; +import { aria } from '../utils.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import {Rect} from '../utils/rect.js'; @@ -358,4 +359,15 @@ export class RenderedWorkspaceComment canBeFocused(): boolean { return true; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + // TODO: Probably shouldn't do this since the textarea itself should already have this role implied. + return aria.Role.TEXTBOX; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'DoNotOverride?'; + } } diff --git a/core/css.ts b/core/css.ts index 4f4a4daaf90..a1aef1e3468 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..6fde818e280 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; + connectionCandidate: ConnectionCandidate | null = null; private connectionPreviewer: IConnectionPreviewer | null = null; diff --git a/core/field.ts b/core/field.ts index fdcb2d693b9..6d4cbefa1fb 100644 --- a/core/field.ts +++ b/core/field.ts @@ -43,6 +43,7 @@ import * as userAgent from './utils/useragent.js'; import * as utilsXml from './utils/xml.js'; import * as WidgetDiv from './widgetdiv.js'; import {WorkspaceSvg} from './workspace_svg.js'; +import * as aria from './utils/aria.js'; /** * A function that is called to validate changes to the field's value before @@ -398,6 +399,9 @@ export abstract class Field }, this.fieldGroup_, ); + // The text itself is presentation since it's represented through the + // block's ARIA label. + aria.setState(this.textElement_, aria.State.HIDDEN, true); if (this.getConstants()!.FIELD_TEXT_BASELINE_CENTER) { this.textElement_.setAttribute('dominant-baseline', 'central'); } @@ -1394,6 +1398,12 @@ export abstract class Field return true; } + /** See IFocusableNode.getAriaRole. */ + abstract getAriaRole(): aria.Role | null; + + /** See IFocusableNode.getAriaLabel. */ + abstract getAriaLabel(): string; + /** * Subclasses should reimplement this method to construct their Field * subclass from a JSON arg object. diff --git a/core/field_checkbox.ts b/core/field_checkbox.ts index 55ed42cbf4b..da8d585887d 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 { aria } from './utils.js'; import * as dom from './utils/dom.js'; type BoolString = 'TRUE' | 'FALSE'; @@ -199,6 +200,16 @@ export class FieldCheckbox extends Field { return String(this.convertValueToBool(this.value_)); } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.CHECKBOX; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return this.name ? `Checkbox ${this.name}` : 'Checkbox'; + } + /** * Convert a value into a pure boolean. * diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 8b01ccddab1..ee1f93729cf 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -303,7 +303,6 @@ export class FieldDropdown extends Field { throw new UnattachedFieldError(); } const menu = new Menu(); - menu.setRole(aria.Role.LISTBOX); this.menu_ = menu; const options = this.getOptions(false); @@ -820,6 +819,16 @@ export class FieldDropdown extends Field { throw TypeError('Found invalid FieldDropdown options.'); } } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.LISTBOX; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return this.name ? `Item ${this.name}` : 'Item'; + } } /** diff --git a/core/field_image.ts b/core/field_image.ts index 01133c20340..c522f491e9b 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 { aria } from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; import {Size} from './utils/size.js'; @@ -254,6 +255,17 @@ export class FieldImage extends Field { return this.altText; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.IMAGE; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + // TODO: This one is used unexpectedly (such as for string input). May need special casing. + return this.name ? `Image ${this.name}` : 'Image'; + } + /** * Construct a FieldImage from a JSON arg object, * dereferencing any string table references. diff --git a/core/field_input.ts b/core/field_input.ts index b685309183a..3f7fafef640 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -774,6 +774,16 @@ export abstract class FieldInput extends Field< protected getValueFromEditorText_(text: string): AnyDuringMigration { return text; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.TEXTBOX; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return this.name ? `Text ${this.name}` : 'Text'; + } } /** diff --git a/core/field_label.ts b/core/field_label.ts index 236154cc7b1..729c4e6bb98 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 { aria } from './utils.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -111,6 +112,18 @@ export class FieldLabel extends Field { this.class = cssClass; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + // There's no additional semantic meaning needed for a label; the aria-label + // should be sufficient for context. + return null; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return this.getText(); + } + /** * Construct a FieldLabel from a JSON arg object, * dereferencing any string table references. diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 492d3341762..8ff298875bb 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -33,6 +33,7 @@ import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; import * as blocks from './serialization/blocks.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'; @@ -995,6 +996,16 @@ export abstract class Flyout return false; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + throw new Error('Flyouts are not directly focusable.'); + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + throw new Error('Flyouts are not directly focusable.'); + } + /** * See IFocusableNode.getRootFocusableNode. * diff --git a/core/flyout_button.ts b/core/flyout_button.ts index c9afb8b0159..98664a72bf6 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -17,7 +17,7 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; 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 {aria, idGenerator} from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import * as parsing from './utils/parsing.js'; @@ -407,6 +407,16 @@ export class FlyoutButton canBeFocused(): boolean { return true; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.BUTTON; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'Button'; + } } /** CSS for buttons and labels. See css.js for use. */ diff --git a/core/flyout_separator.ts b/core/flyout_separator.ts index e9ace428ec9..459ce5ce587 100644 --- a/core/flyout_separator.ts +++ b/core/flyout_separator.ts @@ -7,6 +7,7 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import { aria } from './utils.js'; import {Rect} from './utils/rect.js'; /** @@ -83,6 +84,16 @@ export class FlyoutSeparator implements IBoundedElement, IFocusableNode { canBeFocused(): boolean { return false; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.SEPARATOR; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return ''; + } } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 02e0591070f..d97c16d2231 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -6,6 +6,7 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; @@ -573,7 +574,18 @@ export class FocusManager { if (!elem.hasAttribute('tabindex')) elem.tabIndex = -1; } + // Ensure the node's role and label are up-to-date. + const nodeRole = node.getAriaRole(); + const nodeLabel = node.getAriaLabel(); + if (aria.getRole(elem) !== nodeRole) { + aria.setRole(elem, nodeRole); + } + if (aria.getState(elem, aria.State.LABEL) !== nodeLabel) { + aria.setState(elem, aria.State.LABEL, nodeLabel); + } + this.setNodeToVisualActiveFocus(node); + console.log('@@@@@@ focus element', elem.id); elem.focus(); } diff --git a/core/gesture.ts b/core/gesture.ts index 4c65c1d3842..d443b908adf 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -1065,6 +1065,8 @@ export class Gesture { } } + getTargetBlock(): BlockSvg | null { return this.targetBlock; } + /** * Record the workspace that a gesture started on. * diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 8f5a82c0d15..ae8775e1dae 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -263,6 +263,10 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { return false; } + override getAriaLabel(): string { + return this.bubbleIsVisible() ? 'Close Comment' : 'Open Comment'; + } + /** * Updates the text of this comment in response to changes in the text of * the input bubble. diff --git a/core/icons/icon.ts b/core/icons/icon.ts index 8f8ff70fc32..9b260baac77 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 { aria } from '../utils.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; import * as idGenerator from '../utils/idgenerator.js'; @@ -178,6 +179,16 @@ export abstract class Icon implements IIcon { return true; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.FIGURE; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'Icon'; + } + /** * Returns the block that this icon is attached to. * diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 9055a91ea8f..1292543adf5 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -161,6 +161,10 @@ export class MutatorIcon extends Icon implements IHasBubble { return false; } + override getAriaLabel(): string { + return this.bubbleIsVisible() ? 'Close Mutator' : 'Open Mutator'; + } + bubbleIsVisible(): boolean { return !!this.miniWorkspaceBubble; } diff --git a/core/icons/warning_icon.ts b/core/icons/warning_icon.ts index f24a6a56190..fd09051d144 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -167,6 +167,10 @@ export class WarningIcon extends Icon implements IHasBubble { return false; } + override getAriaLabel(): string { + return this.bubbleIsVisible() ? 'Close Warning' : 'Open Warning'; + } + bubbleIsVisible(): boolean { return !!this.textBubble; } diff --git a/core/inject.ts b/core/inject.ts index 4217c515119..b4d33ecf45d 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,13 @@ export function inject( common.globalShortcutHandler, ); + // See: https://stackoverflow.com/a/48590836 for a reference. + // TODO: Figure out a cleaner way to do this. + const ariaAnnouncementSpan = document.createElement('span'); + ariaAnnouncementSpan.id = 'blocklyAriaAnnounce'; + aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite'); + subContainer.appendChild(ariaAnnouncementSpan); + return workspace; } diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts index 24833328d7f..3ac82ba8df8 100644 --- a/core/interfaces/i_focusable_node.ts +++ b/core/interfaces/i_focusable_node.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { aria } from '../utils.js'; import type {IFocusableTree} from './i_focusable_tree.js'; /** Represents anything that can have input focus. */ @@ -96,6 +97,16 @@ export interface IFocusableNode { * @returns Whether this node can be focused by FocusManager. */ canBeFocused(): boolean; + + // TODO: + // - Make this v12-compatible. + // - Separate it from focusable node (maybe), or better: abstract away the element so that ID, ARIA, etc. can be properly represneted together. + // - This will not work reactively. The screen reader can sometimes announce nodes without them being focused (this was noticed for shadow blocks when moving a parent block--it's not entirely clear why it's valid for this to happen). + getAriaRole(): aria.Role | null; + + // TODO: This is complicated because it largely depends on the role, and whether there's a label for the element. Also, the contract needs more work. Every focusable element must be auditorially descriptive, but that doesn't necessitate a label. + // TODO: Figure out localization for this. A string is probably required since returning a Msg key wouldn't allow for dynamic messages (which will be needed for more complex nodes like blocks). + getAriaLabel(): string; } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 84905eeccc2..1b709ed7635 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 { aria } from './utils.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -656,6 +657,16 @@ export class RenderedConnection return true; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.FIGURE; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'Open connection'; + } + private findHighlightSvg(): SVGElement | null { // This cast is valid as TypeScript's definition is wrong. See: // https://github.com/microsoft/TypeScript/issues/60996. diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index f6291b9f0fa..83e5267e2b9 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -10,6 +10,7 @@ import type {BlockSvg} from '../../block_svg.js'; import type {Connection} from '../../connection.js'; import {RenderedConnection} from '../../rendered_connection.js'; import type {BlockStyle} from '../../theme.js'; +import { aria } from '../../utils.js'; import {Coordinate} from '../../utils/coordinate.js'; import * as dom from '../../utils/dom.js'; import {Svg} from '../../utils/svg.js'; @@ -55,6 +56,7 @@ export class PathObject implements IPathObject { ); this.setClass_('blocklyBlock', true); + aria.setRole(this.svgRoot, aria.Role.PRESENTATION); } /** diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index 7b0db7b3fcd..1811170546e 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -120,6 +120,7 @@ export class ToolboxCategory if (this.toolboxItemDef_['hidden'] === 'true') { this.hide(); } + super.initAria(); } /** @@ -189,7 +190,6 @@ export class ToolboxCategory */ protected createDom_(): HTMLDivElement { this.htmlDiv_ = this.createContainer_(); - 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); diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 5048ff1269d..99e4194ef2d 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -140,6 +140,16 @@ export class CollapsibleToolboxCategory return this.htmlDiv_!; } + override initAria(): void { + super.initAria(); + + // Ensure this group has properly set children. + const focusable = this.getFocusableElement(); + const selectableChildren = this.getChildToolboxItems().filter((item) => item.isSelectable()) ?? null; + const focusableChildIds = selectableChildren.map((selectable) => selectable.getFocusableElement().id); + focusable.setAttribute('aria-owns', [... new Set(focusableChildIds)].join(' ')); + } + override createIconDom_() { const toolboxIcon = document.createElement('span'); if (!this.parentToolbox_.isHorizontal()) { @@ -249,6 +259,10 @@ export class CollapsibleToolboxCategory return this.htmlDiv_; } + override getAriaRole(): aria.Role | null { + return aria.Role.GROUP; + } + /** * Gets any children toolbox items. (ex. Gets the subcategories) * diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index cd5ed245a04..3215365bc8c 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -14,6 +14,7 @@ import * as Css from '../css.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import * as registry from '../registry.js'; +import { aria } from '../utils.js'; import * as dom from '../utils/dom.js'; import type * as toolbox from '../utils/toolbox.js'; import {ToolboxItem} from './toolbox_item.js'; @@ -45,6 +46,7 @@ export class ToolboxSeparator extends ToolboxItem { override init() { this.createDom_(); + super.initAria(); } /** @@ -73,6 +75,10 @@ export class ToolboxSeparator extends ToolboxItem { override dispose() { dom.removeNode(this.htmlDiv as HTMLDivElement); } + + override getAriaRole(): aria.Role | null { + return aria.Role.SEPARATOR; + } } export namespace ToolboxSeparator { diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index f34034d3399..dcaebb0ab69 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -186,15 +186,26 @@ 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); this.attachEvents_(container, this.contentsDiv_); + aria.setRole(container, this.getAriaRole()); return container; } + public 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); + focusable.setAttribute('aria-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) => elem.setAttribute('aria-posinset', `${index + 1}`)); + } + /** * Creates the container div for the toolbox. * @@ -1100,6 +1111,16 @@ export class Toolbox return true; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + return aria.Role.TREE; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + return 'Toolbox'; + } + /** See IFocusableTree.getRootFocusableNode. */ getRootFocusableNode(): IFocusableNode { return this; @@ -1124,7 +1145,7 @@ export class Toolbox /** See IFocusableTree.lookUpFocusableNode. */ lookUpFocusableNode(id: string): IFocusableNode | null { - return this.getToolboxItemById(id) as IFocusableNode; + return this.getToolboxItemById(id); } /** See IFocusableTree.onTreeFocus. */ diff --git a/core/toolbox/toolbox_item.ts b/core/toolbox/toolbox_item.ts index 9fc5c160ddc..04f08988905 100644 --- a/core/toolbox/toolbox_item.ts +++ b/core/toolbox/toolbox_item.ts @@ -15,9 +15,11 @@ import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_ import type {IFocusableTree} from '../interfaces/i_focusable_tree.js'; import type {IToolbox} from '../interfaces/i_toolbox.js'; import type {IToolboxItem} from '../interfaces/i_toolbox_item.js'; +import { aria } from '../utils.js'; import * as idGenerator from '../utils/idgenerator.js'; import type * as toolbox from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; +import { Toolbox } from './toolbox.js'; /** * Class for an item in the toolbox. @@ -70,6 +72,12 @@ export class ToolboxItem implements IToolboxItem { init() {} // No-op by default. + initAria() { + // TODO: Figure out a cleaner way to do this (need to set role ahead of time for tree to behave correctly with readout). + aria.setRole(this.getFocusableElement(), this.getAriaRole()); + (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); + } + /** * Gets the div for the toolbox item. * @@ -177,5 +185,17 @@ export class ToolboxItem implements IToolboxItem { canBeFocused(): boolean { return true; } + + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + // TODO: Figure out a correct default here. + return aria.Role.TREEITEM; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + // TODO: Figure out a correct default here. + return ''; + } } // nop by default diff --git a/core/utils/aria.ts b/core/utils/aria.ts index d997b8d0af0..9050fc05bb8 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -54,6 +54,15 @@ export enum Role { // ARIA role for a live region providing information. STATUS = 'status', + + REGION = 'region', + IMAGE = 'image', + FIGURE = 'figure', + BUTTON = 'button', + CHECKBOX = 'checkbox', + TEXTBOX = 'textbox', + + APPLICATION = 'application', } /** @@ -121,6 +130,10 @@ export enum State { // ARIA property for removing elements from the accessibility tree. // Value: one of {true, false, undefined}. HIDDEN = 'hidden', + + ROLEDESCRIPTION = 'roledescription', + + ATOMIC = 'atomic', } /** @@ -128,11 +141,23 @@ export enum State { * * Similar to Closure's goog.a11y.aria * - * @param element DOM node to set role of. + * @param element DOM node to set role of, or null to remove any set role. * @param roleName Role name. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function setRole(element: Element, roleName: Role | null) { + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else element.removeAttribute(ROLE_ATTRIBUTE); +} + +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; } /** @@ -156,3 +181,8 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + return element.getAttribute(attrStateName); +} diff --git a/core/utils/dom.ts b/core/utils/dom.ts index 4087984151c..3d340ee94ef 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 '../utils/aria.js'; +import {Svg} from './svg.js'; /** * Required name space for SVG elements. @@ -54,6 +55,7 @@ export function createSvgElement( name: string | Svg, attrs: {[key: string]: string | number}, opt_parent?: Element | null, + ariaRole?: aria.Role ): T { const e = document.createElementNS(SVG_NS, `${name}`) as T; for (const key in attrs) { @@ -62,6 +64,12 @@ export function createSvgElement( if (opt_parent) { opt_parent.appendChild(e); } + if (ariaRole) { + aria.setRole(e, ariaRole); + } else if (name === Svg.G || name === Svg.SVG) { + // TODO: Figure out a clean way to do this, this way is a bit ugly. Perhaps createSvgElement() specialization based on type? + aria.setRole(e, aria.Role.PRESENTATION); + } return e; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 6c6b5930110..4caf8741584 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -94,6 +94,7 @@ import * as WidgetDiv from './widgetdiv.js'; import {Workspace} from './workspace.js'; import {WorkspaceAudio} from './workspace_audio.js'; import {ZoomControls} from './zoom_controls.js'; +import { IDraggable, ISelectable } from './blockly.js'; /** Margin around the top/bottom/left/right after a zoomToFit call. */ const ZOOM_TO_FIT_MARGIN = 20; @@ -763,13 +764,7 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); - if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); - } + aria.setRole(this.svgGroup_, this.getAriaRole()); // 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 @@ -1512,6 +1507,11 @@ export class WorkspaceSvg ); } + private movingBlock_: (IDraggable & IFocusableNode & IBoundedElement & ISelectable) | null = null; + + setMovingBlock(movingBlock: (IDraggable & IFocusableNode & IBoundedElement & ISelectable) | null) { this.movingBlock_ = movingBlock; } + getMovingBlock(): (IDraggable & IFocusableNode & IBoundedElement & ISelectable) | null { return this.movingBlock_; } + /** * Is this workspace draggable? * @@ -2717,6 +2717,25 @@ export class WorkspaceSvg return true; } + /** See IFocusableNode.getAriaRole. */ + getAriaRole(): aria.Role | null { + // return aria.Role.REGION; + return aria.Role.TREE; + } + + /** See IFocusableNode.getAriaLabel. */ + getAriaLabel(): string { + if (this.injectionDiv) { + return Msg['WORKSPACE_ARIA_LABEL']; + } else if (this.isFlyout) { + return 'Flyout'; + } else if (this.isMutator) { + return 'Mutator'; + } else { + throw new Error('Cannot determine ARIA label for workspace.'); + } + } + /** See IFocusableTree.getRootFocusableNode. */ getRootFocusableNode(): IFocusableNode { return this;