diff --git a/core/block_svg.ts b/core/block_svg.ts index 6cb1eb85cdf..cede6cfdb36 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -216,15 +216,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, aria.Role.TREEITEM); - this.recomputeAriaLabel(); + aria.setRole(svgPath, this.getAriaRole()); svgPath.tabIndex = -1; - this.currentConnectionCandidate = null; this.doInit_(); + aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getAriaLabel()); } private recomputeAriaLabel() { @@ -339,6 +342,75 @@ export class BlockSvg aria.setState(this.getFocusableElement(), aria.State.SELECTED, false); } + 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. * @@ -355,6 +427,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. @@ -408,7 +509,8 @@ export class BlockSvg this.applyColour(); - this.workspace.recomputeAriaTree(); + // ATTEMPT 4 (full cheating without custom English gen). + this.workspace.getTopBlocks(false).forEach((block) => (block as BlockSvg).recomputeAriaTreeItemDetailsRecursively()); } /** @@ -1840,18 +1942,17 @@ 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.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); - const dragStrategy = this.dragStrategy as BlockDragStrategy; - const candidate = dragStrategy.connectionCandidate?.neighbour ?? null; - this.currentConnectionCandidate = candidate; + this.currentConnectionCandidate = (this.dragStrategy as BlockDragStrategy).connectionCandidate?.neighbour ?? null; this.announceDynamicAriaState(true, false, newLoc); } @@ -1919,10 +2020,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(); } @@ -1931,53 +2034,106 @@ export class BlockSvg 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 block The block whose dynamic state should maybe be announced. - * @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, - ) { + /** 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) { - aria.announceDynamicAriaState('Canceled movement'); + ariaAnnouncementSpan.innerHTML = 'Canceled movement'; return; } if (!isMoving) return; - if (this.currentConnectionCandidate) { + 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. - const surroundParent = this.currentConnectionCandidate.sourceBlock_; + 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 (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) { - announcementContext.push('to', 'input'); + if (connection.type === ConnectionType.INPUT_VALUE) { + // surroundParent = connection.sourceBlock_; + announcementContext.push('to','input','of'); } else { - announcementContext.push('to', 'child'); - } - if (surroundParent) { - announcementContext.push('of', surroundParent.computeAriaLabel()); + // 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. - aria.announceDynamicAriaState(announcementContext.join(' ')); + ariaAnnouncementSpan.innerHTML = 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)}.`, - ); + 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/field.ts b/core/field.ts index 28a0e3977ad..591c6e65ac3 100644 --- a/core/field.ts +++ b/core/field.ts @@ -44,6 +44,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 @@ -399,6 +400,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'); } @@ -1396,6 +1400,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 f7ab38ead56..305e5764667 100644 --- a/core/field_checkbox.ts +++ b/core/field_checkbox.ts @@ -208,6 +208,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 f3badfd6e83..f7366b1db5c 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -311,7 +311,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); @@ -828,6 +827,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 e6ac13e0810..1443a3646ca 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -263,6 +263,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 539fc8a6bea..e43f19b5091 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -782,6 +782,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 901c21bd000..a51c2446e38 100644 --- a/core/field_label.ts +++ b/core/field_label.ts @@ -118,6 +118,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(); + } + override setValue(newValue: any, fireChangeEvent?: boolean): void { super.setValue(newValue, fireChangeEvent); if (this.fieldGroup_) { 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 0e83706709b..c0b4aeb6a24 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -411,6 +411,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 3325493709c..e59d9f2811f 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -274,6 +274,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 eb5b56a8080..7a8551b6d76 100644 --- a/core/icons/icon.ts +++ b/core/icons/icon.ts @@ -182,6 +182,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 af32f55df1c..1f3dfa48d83 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -172,6 +172,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 5085769eec9..bab991e8f7e 100644 --- a/core/icons/warning_icon.ts +++ b/core/icons/warning_icon.ts @@ -178,6 +178,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 5900f4fa718..1f95df57169 100644 --- a/core/inject.ts +++ b/core/inject.ts @@ -85,6 +85,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 f1cab26da33..3455c7997e5 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -659,6 +659,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 d86a41cb613..3ee4f104b84 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -121,6 +121,7 @@ export class ToolboxCategory if (this.toolboxItemDef_['hidden'] === 'true') { this.hide(); } + super.initAria(); } /** @@ -190,7 +191,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); (this.parentToolbox_ as Toolbox).recomputeAriaOwners(); diff --git a/core/toolbox/collapsible_category.ts b/core/toolbox/collapsible_category.ts index 342230fdd1b..fa32e687433 100644 --- a/core/toolbox/collapsible_category.ts +++ b/core/toolbox/collapsible_category.ts @@ -155,6 +155,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()) { @@ -264,6 +274,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 9c2b5e6e2d2..68019863c17 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -47,6 +47,7 @@ export class ToolboxSeparator extends ToolboxItem { override init() { this.createDom_(); + super.initAria(); } /** @@ -79,6 +80,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 3b87f1ef2c2..5ca73bc3f58 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -193,9 +193,21 @@ export class Toolbox 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. * @@ -1101,6 +1113,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; @@ -1125,7 +1147,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 aa9ec3f29d6..55f25f9d1e3 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -46,11 +46,14 @@ 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', } /** @@ -108,15 +111,19 @@ export enum State { HIDDEN = 'hidden', ROLEDESCRIPTION = 'roledescription', + + ATOMIC = 'atomic', + OWNS = 'owns', } /** * Updates the specific role for the specified element. * - * @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. + * Similar to Closure's goog.a11y.aria + * + * @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 | null) { if (roleName) { @@ -124,13 +131,6 @@ export function setRole(element: Element, roleName: Role | null) { } 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 The element from which to retrieve its ARIA role. - * @returns The ARIA role of the element, or null if undefined or unknown. - */ 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. @@ -165,18 +165,6 @@ export function setState( 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); diff --git a/core/utils/dom.ts b/core/utils/dom.ts index e32cad4d604..62a20a893d9 100644 --- a/core/utils/dom.ts +++ b/core/utils/dom.ts @@ -55,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) { @@ -63,7 +64,10 @@ export function createSvgElement( if (opt_parent) { opt_parent.appendChild(e); } - if (name === Svg.SVG || name === Svg.G) { + 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 682a94b4ec9..b43256f31af 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -95,6 +95,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; @@ -764,19 +765,7 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); - - let ariaLabel = null; - if (injectionDiv) { - ariaLabel = Msg['WORKSPACE_ARIA_LABEL']; - } else if (this.isFlyout) { - ariaLabel = 'Flyout'; - } else if (this.isMutator) { - ariaLabel = 'Mutator'; - } else { - throw new Error('Cannot determine ARIA label for workspace.'); - } - aria.setState(this.svgGroup_, aria.State.LABEL, ariaLabel); - aria.setRole(this.svgGroup_, aria.Role.TREE); + 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 @@ -1519,6 +1508,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? * @@ -2727,6 +2721,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;