-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat: Add basic support for generating ARIA labels and roles for blocks #9696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gonfunko
wants to merge
10
commits into
v13
Choose a base branch
from
puccini
base: v13
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+614
−5
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
7454ffd
feat: Add basic support for generating ARIA labels and roles for blocks
gonfunko 7da0556
test: Add tests
gonfunko bd7fd5c
chore: Fix lint
gonfunko c282b2b
chore: Revert tooling removal of authors
gonfunko 0f80a69
chore: Adjust casing of method name
gonfunko 1df8ae3
chore: Tweak name of verbosity enum value
gonfunko 02fc184
chore: Adjust name of shadow block label method
gonfunko 7197831
chore: Add trailing newline
gonfunko 3a9ebc3
chore: Fix method casing
gonfunko 3547644
feat: Add method to retrieve a block's ARIA label
gonfunko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Raspberry Pi Foundation | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type {BlockSvg} from './block_svg.js'; | ||
| import {ConnectionType} from './connection_type.js'; | ||
| import type {Input} from './inputs/input.js'; | ||
| import {inputTypes} from './inputs/input_types.js'; | ||
| import { | ||
| ISelectableToolboxItem, | ||
| isSelectableToolboxItem, | ||
| } from './interfaces/i_selectable_toolbox_item.js'; | ||
| import {Msg} from './msg.js'; | ||
| import {Role, setRole, setState, State, Verbosity} from './utils/aria.js'; | ||
|
|
||
| /** | ||
| * Returns an ARIA representation of the specified block. | ||
| * | ||
| * The returned label will contain a complete context of the block, including: | ||
| * - Whether it begins a block stack or statement input stack. | ||
| * - Its constituent editable and non-editable fields. | ||
| * - Properties, including: disabled, collapsed, replaceable (a shadow), etc. | ||
| * - Its parent toolbox category. | ||
| * - Whether it has inputs. | ||
| * | ||
| * Beyond this, the returned label is specifically assembled with commas in | ||
| * select locations with the intention of better 'prosody' in the screen reader | ||
| * readouts since there's a lot of information being shared with the user. The | ||
| * returned label also places more important information earlier in the label so | ||
| * that the user gets the most important context as soon as possible in case | ||
| * they wish to stop readout early. | ||
| * | ||
| * The returned label will be specialized based on whether the block is part of a | ||
| * flyout. | ||
| * | ||
| * @internal | ||
| * @param block The block for which an ARIA representation should be created. | ||
| * @param verbosity How much detail to include in the description. | ||
| * @returns The ARIA representation for the specified block. | ||
| */ | ||
| export function computeAriaLabel( | ||
| block: BlockSvg, | ||
| verbosity = Verbosity.STANDARD, | ||
| ) { | ||
| return [ | ||
| getBeginStackLabel(block), | ||
| getParentInputLabel(block), | ||
| ...getInputLabels(block), | ||
| verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block), | ||
| verbosity >= Verbosity.STANDARD && getDisabledLabel(block), | ||
| verbosity >= Verbosity.STANDARD && getCollapsedLabel(block), | ||
| verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block), | ||
| verbosity >= Verbosity.STANDARD && getInputCountLabel(block), | ||
| ] | ||
| .filter((label) => !!label) | ||
| .join(', '); | ||
| } | ||
|
|
||
| /** | ||
| * Sets the ARIA role and role description for the specified block, accounting | ||
| * for whether the block is part of a flyout. | ||
| * | ||
| * @internal | ||
| * @param block The block to set ARIA role and roledescription attributes on. | ||
| */ | ||
| export function configureAriaRole(block: BlockSvg) { | ||
| setRole(block.getSvgRoot(), block.isInFlyout ? Role.LISTITEM : Role.FIGURE); | ||
|
|
||
| let roleDescription = Msg['BLOCK_LABEL_STATEMENT']; | ||
| if (block.statementInputCount) { | ||
| roleDescription = Msg['BLOCK_LABEL_CONTAINER']; | ||
| } else if (block.outputConnection) { | ||
| roleDescription = Msg['BLOCK_LABEL_VALUE']; | ||
| } | ||
|
|
||
| setState(block.getSvgRoot(), State.ROLEDESCRIPTION, roleDescription); | ||
| } | ||
|
|
||
| /** | ||
| * Returns an ARIA representation of the 'field row' for the specified Input. | ||
| * | ||
| * 'Field row' essentially means the horizontal run of readable fields that | ||
| * precede the Input. Together, these provide the domain context for the input, | ||
| * particularly in the context of connections. In some cases, there may not be | ||
| * any readable fields immediately prior to the Input. In that case, if the | ||
| * `lookback` attribute is specified, all of the fields on the row immediately | ||
| * above the Input will be used instead. | ||
| * | ||
| * Returns undefined if no fields precede the given Input. | ||
| * | ||
| * @internal | ||
| * @param input The Input to compute a description/context label for. | ||
| * @param lookback If true, will use fields on the previous row to compute a | ||
| * label for the given input if it has no fields itself. | ||
| * @returns An accessibility label for the given input, or undefined if one | ||
| * cannot be computed. | ||
| */ | ||
| export function computeFieldRowLabel( | ||
| input: Input, | ||
| lookback: boolean, | ||
| ): string[] { | ||
| const fieldRowLabel = input.fieldRow | ||
| .filter((field) => field.isVisible()) | ||
| .map((field) => field.computeAriaLabel(true)); | ||
| if (!fieldRowLabel.length && lookback) { | ||
| const inputs = input.getSourceBlock().inputList; | ||
| const index = inputs.indexOf(input); | ||
| if (index > 0) { | ||
| return computeFieldRowLabel(inputs[index - 1], lookback); | ||
| } | ||
| } | ||
| return fieldRowLabel; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a description of the parent statement input a block is attached to. | ||
| * When a block is connected to a statement input, the input's field row label | ||
| * will be prepended to the block's description to indicate that the block | ||
| * begins a clause in its parent block. | ||
| * | ||
| * @internal | ||
| * @param block The block to generate a parent input label for. | ||
| * @returns A description of the block's parent statement input, or undefined | ||
| * for blocks that do not have one. | ||
| */ | ||
| function getParentInputLabel(block: BlockSvg) { | ||
| const parentInput = ( | ||
| block.outputConnection ?? block.previousConnection | ||
| )?.targetConnection?.getParentInput(); | ||
| const parentBlock = parentInput?.getSourceBlock(); | ||
|
|
||
| if (!parentBlock?.statementInputCount) return undefined; | ||
|
|
||
| const firstStatementInput = parentBlock.inputList.find( | ||
| (i) => i.type === inputTypes.STATEMENT, | ||
| ); | ||
| // The first statement input in a block has no field row label as it would | ||
| // be duplicative of the block's label. | ||
| if (!parentInput || parentInput === firstStatementInput) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const parentInputLabel = computeFieldRowLabel(parentInput, true); | ||
| return parentInput.type === inputTypes.STATEMENT | ||
| ? Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', parentInputLabel.join(' ')) | ||
| : parentInputLabel; | ||
| } | ||
|
|
||
| /** | ||
| * Returns text indicating that a block is the root block of a stack. | ||
| * | ||
| * @internal | ||
| * @param block The block to retrieve a label for. | ||
| * @returns Text indicating that the block begins a stack, or undefined if it | ||
| * does not. | ||
| */ | ||
| function getBeginStackLabel(block: BlockSvg) { | ||
| return !block.workspace.isFlyout && block.getRootBlock() === block | ||
| ? Msg['BLOCK_LABEL_BEGIN_STACK'] | ||
| : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a list of accessibility labels for fields and inputs on a block. | ||
| * Each entry in the returned array corresponds to one of: (a) a label for a | ||
| * continuous run of non-interactable fields, (b) a label for an editable field, | ||
| * (c) a label for an input. When an input contains nested blocks/fields/inputs, | ||
| * their contents are returned as a single item in the array per top-level | ||
| * input. | ||
| * | ||
| * @internal | ||
| * @param block The block to retrieve a list of field/input labels for. | ||
| * @returns A list of field/input labels for the given block. | ||
| */ | ||
| function getInputLabels(block: BlockSvg): string[] { | ||
| return block.inputList | ||
| .filter((input) => input.isVisible()) | ||
| .flatMap((input) => { | ||
| const labels = computeFieldRowLabel(input, false); | ||
|
|
||
| if (input.connection?.type === ConnectionType.INPUT_VALUE) { | ||
| const childBlock = input.connection.targetBlock(); | ||
| if (childBlock) { | ||
| labels.push(getInputLabels(childBlock as BlockSvg).join(' ')); | ||
| } | ||
| } | ||
|
|
||
| return labels; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the name of the toolbox category that the given block is part of. | ||
| * This is heuristic-based; each toolbox category's contents are enumerated, and | ||
| * if a block with the given block's type is encountered, that category is | ||
| * deemed to be its parent. As a fallback, a toolbox category with the same | ||
| * colour as the block may be returned. This is not comprehensive; blocks may | ||
| * exist on the workspace which are not part of any category, or a given block | ||
| * type may be part of multiple categories or belong to a dynamically-generated | ||
| * category, or there may not even be a toolbox at all. In these cases, either | ||
| * the first matching category or undefined will be returned. | ||
| * | ||
| * This method exists to attempt to provide similar context as block colour | ||
| * provides to sighted users, e.g. where a red block comes from a red category. | ||
| * It is inherently best-effort due to the above-mentioned constraints. | ||
| * | ||
| * @internal | ||
| * @param block The block to retrieve a category name for. | ||
| * @returns A description of the given block's parent toolbox category if any, | ||
| * otherwise undefined. | ||
| */ | ||
| function getParentToolboxCategoryLabel(block: BlockSvg) { | ||
| const toolbox = block.workspace.getToolbox(); | ||
| if (!toolbox) return undefined; | ||
|
|
||
| let parentCategory: ISelectableToolboxItem | undefined = undefined; | ||
| for (const category of toolbox.getToolboxItems()) { | ||
| if (!isSelectableToolboxItem(category)) continue; | ||
|
|
||
| const contents = category.getContents(); | ||
| if ( | ||
| Array.isArray(contents) && | ||
| contents.some( | ||
| (item) => | ||
| item.kind.toLowerCase() === 'block' && | ||
| 'type' in item && | ||
| item.type === block.type, | ||
| ) | ||
| ) { | ||
| parentCategory = category; | ||
| break; | ||
| } | ||
|
|
||
| if ( | ||
| 'getColour' in category && | ||
| typeof category.getColour === 'function' && | ||
| category.getColour() === block.getColour() | ||
| ) { | ||
| parentCategory = category; | ||
| } | ||
| } | ||
|
|
||
| if (parentCategory) { | ||
| return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace( | ||
| '%1', | ||
| parentCategory.getName(), | ||
| ); | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a label indicating that the block is disabled. | ||
| * | ||
| * @internal | ||
| * @param block The block to generate a label for. | ||
| * @returns A label indicating that the block is disabled (if it is), otherwise | ||
| * undefined. | ||
| */ | ||
| export function getDisabledLabel(block: BlockSvg) { | ||
| return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED']; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a label indicating that the block is collapsed. | ||
| * | ||
| * @internal | ||
| * @param block The block to generate a label for. | ||
| * @returns A label indicating that the block is collapsed (if it is), otherwise | ||
| * undefined. | ||
| */ | ||
| function getCollapsedLabel(block: BlockSvg) { | ||
| return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a label indicating that the block is a shadow block. | ||
| * | ||
| * @internal | ||
| * @param block The block to generate a label for. | ||
| * @returns A label indicating that the block is a shadow (if it is), otherwise | ||
| * undefined. | ||
| */ | ||
| function getShadowBlockLabel(block: BlockSvg) { | ||
| return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Returns a label indicating whether the block has one or multiple inputs. | ||
| * | ||
| * @internal | ||
| * @param block The block to generate a label for. | ||
| * @returns A label indicating that the block has one or multiple inputs, | ||
| * otherwise undefined. | ||
| */ | ||
| function getInputCountLabel(block: BlockSvg) { | ||
| const inputCount = block.inputList.reduce((totalSum, input) => { | ||
| return ( | ||
| input.fieldRow.reduce((fieldCount, field) => { | ||
| return field.EDITABLE && !field.isFullBlockField() | ||
| ? fieldCount++ | ||
| : fieldCount; | ||
| }, totalSum) + | ||
| (input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0) | ||
| ); | ||
| }, 0); | ||
|
|
||
| switch (inputCount) { | ||
| case 0: | ||
| return undefined; | ||
| case 1: | ||
| return Msg['BLOCK_LABEL_HAS_INPUT']; | ||
| default: | ||
| return Msg['BLOCK_LABEL_HAS_INPUTS']; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't match reality, should this method join the array and return it as string?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doing that loses some of the commas in the full block readout. I think I'd kind of prefer to have this always return a list, and not export it?