Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion packages/blockly/core/block_flyout_inflater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 52 additions & 0 deletions packages/blockly/core/flyout_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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_,
Expand Down Expand Up @@ -632,6 +636,7 @@ export abstract class Flyout
this.width_ = 0;
}
this.reflow();
this.updateAriaContext();
eventUtils.setRecordUndo(true);
this.workspace_.setResizesEnabled(true);

Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion packages/blockly/core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -134,6 +135,7 @@ export class FlyoutButton
},
this.svgGroup!,
);
aria.setRole(shadow, aria.Role.PRESENTATION);
}
// Background rectangle.
const rect = dom.createSvgElement(
Expand All @@ -147,6 +149,7 @@ export class FlyoutButton
},
this.svgGroup!,
);
aria.setRole(rect, aria.Role.PRESENTATION);

const svgText = dom.createSvgElement(
Svg.TEXT,
Expand All @@ -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');
Expand Down
9 changes: 9 additions & 0 deletions packages/blockly/core/interfaces/i_flyout_inflater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/blockly/core/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/blockly/core/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ export function createSvgElement<T extends SVGElement>(
): 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() ||
name === Svg.G.toString() ||
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]}`);
Expand Down
64 changes: 57 additions & 7 deletions packages/blockly/core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include workspace comments?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, microbit said just block stacks. You can press i on the workspace to hear more details including how many comments there are

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.
*
Expand All @@ -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 <g> alone does not receive mouse events--it must have a
// valid target inside it. If no background class is specified, as in the
Expand Down Expand Up @@ -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_,
Expand Down
9 changes: 7 additions & 2 deletions packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
}
11 changes: 8 additions & 3 deletions packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"@metadata": {
"@metadata": {
"authors": [
"Ajeje Brazorf",
"Amire80",
Expand Down Expand Up @@ -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}}",
Expand Down Expand Up @@ -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.'",
Expand Down Expand Up @@ -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 <button> elements in your language.",
"ARIA_LABEL_HEADING": "Part of an aria label for an element that indicates it is a heading, but for technial reasons cannot be given a role of heading. Ideally, this would match the localized name for what screenreaders announce for <h1> elements in your language."
}
Loading