diff --git a/packages/blockly/core/block_aria_composer.ts b/packages/blockly/core/block_aria_composer.ts index 03e53458b..c2b661163 100644 --- a/packages/blockly/core/block_aria_composer.ts +++ b/packages/blockly/core/block_aria_composer.ts @@ -81,7 +81,11 @@ export function computeAriaLabel( export function configureAriaRole(block: BlockSvg) { setRole(block.getSvgRoot(), Role.PRESENTATION); const focusableElement = block.getFocusableElement(); - setRole(focusableElement, block.isInFlyout ? Role.LISTITEM : Role.FIGURE); + if (!block.isInFlyout) { + // blocks in the flyout have their role set by the Flyout's block inflater + // don't overwrite it here + setRole(focusableElement, Role.FIGURE); + } let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; if (block.statementInputCount) { diff --git a/packages/blockly/core/block_flyout_inflater.ts b/packages/blockly/core/block_flyout_inflater.ts index 80f868551..710e4efd3 100644 --- a/packages/blockly/core/block_flyout_inflater.ts +++ b/packages/blockly/core/block_flyout_inflater.ts @@ -15,6 +15,7 @@ import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; import * as registry from './registry.js'; import * as blocks from './serialization/blocks.js'; +import {aria} from './utils.js'; import type {BlockInfo} from './utils/toolbox.js'; import * as utilsXml from './utils/xml.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -67,7 +68,19 @@ export class BlockFlyoutInflater implements IFlyoutInflater { // Mark blocks as being inside a flyout. This is used to detect and // prevent the closure of the flyout if the user right-clicks on such // a block. - block.getDescendants(false).forEach((b) => (b.isInFlyout = true)); + block.getDescendants(false).forEach((b) => { + b.isInFlyout = true; + const focusableElement = b.getFocusableElement(); + // blocks can't be focused if they're in a flyout and not top-level + // nonfocusable blocks should be hidden from the aria tree + aria.setState(focusableElement, aria.State.HIDDEN, true); + aria.setRole(focusableElement, aria.Role.PRESENTATION); + }); + // Since getDescencdants includes the root block, we need + // to correct the role and hidden state for it. + const focusableElement = block.getFocusableElement(); + aria.clearState(focusableElement, aria.State.HIDDEN); + aria.setRole(focusableElement, aria.Role.LISTITEM); this.addBlockListeners(block); return new FlyoutItem(block, BLOCK_TYPE); diff --git a/packages/blockly/core/flyout_base.ts b/packages/blockly/core/flyout_base.ts index eab100253..c973b1775 100644 --- a/packages/blockly/core/flyout_base.ts +++ b/packages/blockly/core/flyout_base.ts @@ -24,12 +24,15 @@ import {getFocusManager} from './focus_manager.js'; import {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; +import {isSelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js'; import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js'; +import {Msg} from './msg.js'; import type {Options} from './options.js'; import * as registry from './registry.js'; import * as renderManagement from './render_management.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import {SEPARATOR_TYPE} from './separator_flyout_inflater.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import * as idGenerator from './utils/idgenerator.js'; import {Svg} from './utils/svg.js'; @@ -312,6 +315,7 @@ export abstract class Flyout init(targetWorkspace: WorkspaceSvg) { this.targetWorkspace = targetWorkspace; this.workspace_.targetWorkspace = targetWorkspace; + this.workspace_.setInitialAriaContext(); this.workspace_.scrollbar = new ScrollbarPair( this.workspace_, @@ -632,6 +636,7 @@ export abstract class Flyout this.width_ = 0; } this.reflow(); + this.updateAriaContext(); eventUtils.setRecordUndo(true); this.workspace_.setResizesEnabled(true); @@ -650,6 +655,53 @@ export abstract class Flyout this.workspace_.addChangeListener(this.reflowWrapper); } + /** + * Updates the aria attributes for the entire flyout dom. + * This needs to do two things: + * 1. Set aria-owns on the flyout's workspace canvas to include the ids of all + * focusable elements in the flyout. + * 2. Update the aria attributes on the flyout's workspace. This can't be done at workspace + * creation because the workspace may not have all required information until the flyout + * is fully shown. + */ + protected updateAriaContext() { + // Set aria-owns on the flyout's workspace canvas to include the ids of all focusable elements in the flyout. + // This is probably not necessary if the listitems are all direct descendants of the canvas, but + // we can't know the dom structure of the flyout contents, so it's best to be explicit. + const focusableIds = this.getContents() + .map((item) => item.getElement()) + .filter((item) => item.canBeFocused()) + .map((item) => item.getFocusableElement().id); + aria.setState( + this.getWorkspace().getCanvas(), + aria.State.OWNS, + focusableIds.join(' '), + ); + + // Update aria attributes on the flyout's workspace. + // Only call a flyout's workspace a region if it's not auto-closing and not a mutator + if (!this.targetWorkspace.isMutator && !this.autoClose) { + aria.setRole(this.getWorkspace().svgGroup_, aria.Role.REGION); + } else { + aria.setRole(this.getWorkspace().svgGroup_, aria.Role.PRESENTATION); + } + + // the label for a flyout includes the category name if it's available + const selectedItem = this.targetWorkspace.getToolbox()?.getSelectedItem(); + const selectedItemName = + selectedItem && isSelectableToolboxItem(selectedItem) + ? selectedItem.getName() + : ''; + const ariaLabel = Msg['WORKSPACE_LABEL_FLYOUT_WORKSPACE'] + .replace('%1', selectedItemName) + .trim(); + aria.setState(this.getWorkspace().getCanvas(), aria.State.LABEL, ariaLabel); + + // The block canvas is a list. The list items must be direct descendants of the list, + // and the flyout may or may not be a region, so we set the role on the block canvas rather than the svgGroup_. + aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LIST); + } + /** * Create the contents array and gaps array necessary to create the layout for * the flyout. diff --git a/packages/blockly/core/flyout_button.ts b/packages/blockly/core/flyout_button.ts index 3396258c6..00782a19a 100644 --- a/packages/blockly/core/flyout_button.ts +++ b/packages/blockly/core/flyout_button.ts @@ -17,7 +17,8 @@ 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 {Msg} from './msg.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'; @@ -134,6 +135,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(shadow, aria.Role.PRESENTATION); } // Background rectangle. const rect = dom.createSvgElement( @@ -147,6 +149,7 @@ export class FlyoutButton }, this.svgGroup!, ); + aria.setRole(rect, aria.Role.PRESENTATION); const svgText = dom.createSvgElement( Svg.TEXT, @@ -170,6 +173,13 @@ export class FlyoutButton .getThemeManager() .subscribe(this.svgText, 'flyoutForegroundColour', 'fill'); } + aria.setRole(svgText, aria.Role.PRESENTATION); + + // We add the word "heading" or "button" to the label so that they give appropriate hints + // we can't use the corresponding roles because that overwrites the context of it being a list item. + const ariaLabel = `${text}, ${this.isFlyoutLabel ? Msg['ARIA_LABEL_HEADING'] : Msg['ARIA_LABEL_BUTTON']}`; + aria.setState(this.getFocusableElement(), aria.State.LABEL, ariaLabel); + aria.setRole(this.getFocusableElement(), aria.Role.LISTITEM); const fontSize = style.getComputedStyle(svgText, 'fontSize'); const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); diff --git a/packages/blockly/core/interfaces/i_flyout_inflater.ts b/packages/blockly/core/interfaces/i_flyout_inflater.ts index e3c1f5db4..d8d815282 100644 --- a/packages/blockly/core/interfaces/i_flyout_inflater.ts +++ b/packages/blockly/core/interfaces/i_flyout_inflater.ts @@ -8,6 +8,15 @@ export interface IFlyoutInflater { * Note that this method's interface is identical to that in ISerializer, to * allow for code reuse. * + * You must ensure that any item created by this method has the appropriate + * ARIA markup: + * - The role of the element's focusable element should be set to `listitem`. + * - The focusable element must have an `id` attribute. + * - Any DOM parents of the focusable element should set their role to + * `presentation` to avoid interfering with flyout list navigation. + * - If the element is not focusable, it must be hidden from the ARIA tree. + * Only do this if the content should be inaccessible to screenreaders. + * * @param state A JSON representation of an element to inflate on the flyout. * @param flyout The flyout on whose workspace the inflated element * should be created. If the inflated element is an `IRenderedElement` it diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 69e86549f..ebcb5ebf8 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -214,6 +214,13 @@ export enum State { * Value: a number representing the minimum allowed value for a range widget. */ VALUEMIN = 'valuemin', + + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-owns + * + * Value: a space-separated list of element IDs that are owned by the current element. + */ + OWNS = 'owns', } /** diff --git a/packages/blockly/core/utils/dom.ts b/packages/blockly/core/utils/dom.ts index 37bccb578..ea279080d 100644 --- a/packages/blockly/core/utils/dom.ts +++ b/packages/blockly/core/utils/dom.ts @@ -58,7 +58,7 @@ export function createSvgElement( ): T { const e = document.createElementNS(SVG_NS, `${name}`) as T; /** - * For svg and group (g) elements, we set the role to generic so that they are ignored by assistive technologies. + * For svg and group (g) elements, we set the role to presentation so that they are ignored by assistive technologies. */ if ( name === Svg.SVG.toString() || @@ -66,7 +66,7 @@ export function createSvgElement( e.tagName === Svg.SVG.toString() || e.tagName === Svg.G.toString() ) { - aria.setRole(e, aria.Role.GENERIC); + aria.setRole(e, aria.Role.PRESENTATION); } for (const key in attrs) { e.setAttribute(key, `${attrs[key]}`); diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 6b548647a..dd1afe50e 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -698,6 +698,53 @@ export class WorkspaceSvg this.resizeHandlerWrapper = handler; } + /** + * Sets Aria labels, roles, etc. for the workspace depending on the type of workspace it is. + */ + setInitialAriaContext() { + if (!this.svgGroup_) { + throw new Error( + 'Must initialize svgGroup_ by calling `createDom` before calling setAriaContext', + ); + } + if (this.isFlyout) { + // Flyouts have their aria attributes set when the flyout is shown. + return; + } + aria.setRole(this.svgGroup_, aria.Role.REGION); + if (this.isMutator) { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'], + ); + } else { + // Main workspaces get labelled with how many stacks of blocks they contain + // This will be updated in a change listener, but set it here in case there are blocks in the initial state of the workspace + this.updateAriaLabel(); + } + } + + /** + * Updates the label on the workspace to reflect the number of top-level stacks in the workspace. + */ + private updateAriaLabel() { + const numStacks = this.getTopBlocks(false).length; + if (numStacks == 1) { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_1_STACK'], + ); + } else { + aria.setState( + this.svgGroup_, + aria.State.LABEL, + Msg['WORKSPACE_LABEL_MANY_STACKS'].replace('%1', String(numStacks)), + ); + } + } + /** * Create the workspace DOM elements. * @@ -722,13 +769,6 @@ export class WorkspaceSvg 'class': 'blocklyWorkspace', 'id': this.id, }); - if (injectionDiv) { - aria.setState( - this.svgGroup_, - aria.State.LABEL, - Msg['WORKSPACE_ARIA_LABEL'], - ); - } // 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 @@ -756,6 +796,16 @@ export class WorkspaceSvg this.svgBlockCanvas_ = this.layerManager.getBlockLayer(); this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer(); + this.setInitialAriaContext(); + + if (!this.isFlyout && !this.isMutator) { + // Set up a change listener to update the aria label on main workspace + this.addChangeListener((e) => { + if (e.isUiEvent) return; + this.updateAriaLabel(); + }); + } + if (!this.isFlyout) { browserEvents.conditionalBind( this.svgGroup_, diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index ba25a150d..6abea93e2 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -394,7 +394,6 @@ "PROCEDURES_IFRETURN_HELPURL": "https://c2.com/cgi/wiki?GuardClause", "PROCEDURES_IFRETURN_WARNING": "Warning: This block may be used only within a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "Say something...", - "WORKSPACE_ARIA_LABEL": "Blockly Workspace", "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", "DIALOG_CANCEL": "Cancel", @@ -456,6 +455,10 @@ "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste.", + "WORKSPACE_LABEL_1_STACK": "Blocks workspace. 1 stack of blocks", + "WORKSPACE_LABEL_MANY_STACKS": "Blocks workspace. %1 stacks of blocks", + "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Block editor workspace", + "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "%1 blocks", "WORKSPACE_CONTENTS_BLOCKS_MANY": "%1 stacks of blocks%2 in workspace.", "WORKSPACE_CONTENTS_BLOCKS_ONE": "One stack of blocks%2 in workspace.", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "No blocks%2 in workspace.", @@ -494,5 +497,7 @@ "FIELD_LABEL_OPTION_INDEX": "Option %1", "FIELD_LABEL_CHECKBOX_CHECKED": "Checked", "FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked", - "FIELD_LABEL_VARIABLE": "Variable '%1'" + "FIELD_LABEL_VARIABLE": "Variable '%1'", + "ARIA_LABEL_BUTTON": "button", + "ARIA_LABEL_HEADING": "heading" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 4844a14c0..02cb864cb 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -1,5 +1,5 @@ { - "@metadata": { + "@metadata": { "authors": [ "Ajeje Brazorf", "Amire80", @@ -402,7 +402,6 @@ "PROCEDURES_IFRETURN_HELPURL": "{{Optional}} url - Information about guard clauses.", "PROCEDURES_IFRETURN_WARNING": "warning - This appears if the user tries to use this block outside of a function definition.", "WORKSPACE_COMMENT_DEFAULT_TEXT": "comment text - This text appears in a new workspace comment, to hint that the user can type here.", - "WORKSPACE_ARIA_LABEL": "workspace - This text is read out when a user navigates to the workspace while using a screen reader.", "COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.", "DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}", "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}", @@ -464,6 +463,10 @@ "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode.", + "WORKSPACE_LABEL_1_STACK": "Aria label for a workspace with one stack of blocks.", + "WORKSPACE_LABEL_MANY_STACKS": "Aria label for a workspace with 0 or >1 stacks of blocks. \n\nParameters:\n* %1 - the number of stacks of blocks. A stack of blocks is a group of connected blocks that are not connected to any other blocks. 0 stacks means there are no blocks on the workspace.", + "WORKSPACE_LABEL_MUTATOR_WORKSPACE": "Aria label for a mutator workspace, which is a secondary workspace used for editing a block's structure. This type of workspace appears when a user clicks on the gear icon of a block that has a mutator, and allows the user to add, remove, or rearrange inputs to that block.", + "WORKSPACE_LABEL_FLYOUT_WORKSPACE": "Aria label for an always-open flyout's workspace. Since the flyout will have a role of list, the resulting screenreader output will be something like 'Logic blocks list, with 5 items'. Do not include the word 'list' in this message. Parameters: %1 - the category of blocks in the flyout, e.g. 'Logic' or 'Math'. This may be empty for an uncategorized flyout.", "WORKSPACE_CONTENTS_BLOCKS_MANY": "ARIA live region message announcing the number of stacks of blocks in the workspace, optionally including comments. \n\nParameters:\n* %1 - the number of stacks (integer greater than 1)\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* '5 stacks of blocks in workspace.'\n* '5 stacks of blocks and 2 comments in workspace.'", "WORKSPACE_CONTENTS_BLOCKS_ONE": "ARIA live region message announcing there is one stack of blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'One stack of blocks in workspace.'\n* 'One stack of blocks and 1 comment in workspace.'", "WORKSPACE_CONTENTS_BLOCKS_ZERO": "ARIA live region message announcing there are no blocks in the workspace, optionally including a count of comments. \n\nParameters:\n* %2 - optional phrase announcing comments, including leading space \n\nExamples:\n* 'No blocks in workspace.'\n* 'No blocks and 3 comments in workspace.'", @@ -502,5 +505,7 @@ "FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'", "FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.", "FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.", - "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''" + "FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''", + "ARIA_LABEL_BUTTON": "Part of an aria label for an element that indicates it is a button, but for technical reasons cannot be give a role of button. Ideally, this would match the localized name for what screenreaders announce for