mirror of
https://github.com/google/blockly.git
synced 2026-01-09 01:50:11 +01:00
fix: Miscellaneous improvements for screenreader support. (#9424)
* fix: Miscellaneous improvements for screenreader support. * fix: Include field name in ARIA label. * fix: Update block ARIA labels when inputs are shown/hidden. * fix: Make field row label generation more robust.
This commit is contained in:
@@ -226,7 +226,17 @@ export class BlockSvg
|
||||
this.computeAriaRole();
|
||||
}
|
||||
|
||||
private recomputeAriaLabel() {
|
||||
/**
|
||||
* Updates the ARIA label of this block to reflect its current configuration.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
recomputeAriaLabel() {
|
||||
if (this.isSimpleReporter()) {
|
||||
const field = Array.from(this.getFields())[0];
|
||||
if (field.isFullBlockField() && field.isCurrentlyEditable()) return;
|
||||
}
|
||||
|
||||
aria.setState(
|
||||
this.getFocusableElement(),
|
||||
aria.State.LABEL,
|
||||
@@ -240,34 +250,42 @@ export class BlockSvg
|
||||
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
|
||||
: '';
|
||||
|
||||
let currentBlock: Block | null = null;
|
||||
let currentBlock: BlockSvg | null = null;
|
||||
let nestedStatementBlockCount = 0;
|
||||
// This won't work well for if/else blocks.
|
||||
this.inputList.forEach((input) => {
|
||||
|
||||
for (const input of this.inputList) {
|
||||
if (
|
||||
input.connection &&
|
||||
input.connection.type === ConnectionType.NEXT_STATEMENT
|
||||
) {
|
||||
currentBlock = input.connection.targetBlock();
|
||||
currentBlock = input.connection.targetBlock() as BlockSvg | null;
|
||||
while (currentBlock) {
|
||||
nestedStatementBlockCount++;
|
||||
currentBlock = currentBlock.getNextBlock();
|
||||
}
|
||||
}
|
||||
});
|
||||
// The type is poorly inferred here.
|
||||
while (currentBlock as Block | null) {
|
||||
nestedStatementBlockCount++;
|
||||
// The type is poorly inferred here.
|
||||
// If currentBlock is null, we can't enter this while loop...
|
||||
currentBlock = currentBlock!.getNextBlock();
|
||||
}
|
||||
|
||||
let blockTypeText = 'block';
|
||||
if (this.isShadow()) {
|
||||
blockTypeText = 'input block';
|
||||
} else if (this.outputConnection) {
|
||||
blockTypeText = 'replacable block';
|
||||
} else if (this.outputConnection) {
|
||||
blockTypeText = 'input block';
|
||||
} else if (this.statementInputCount) {
|
||||
blockTypeText = 'C-shaped block';
|
||||
}
|
||||
|
||||
const modifiers = [];
|
||||
if (!this.isEnabled()) {
|
||||
modifiers.push('disabled');
|
||||
}
|
||||
if (this.isCollapsed()) {
|
||||
modifiers.push('collapsed');
|
||||
}
|
||||
if (modifiers.length) {
|
||||
blockTypeText = `${modifiers.join(' ')} ${blockTypeText}`;
|
||||
}
|
||||
|
||||
let prefix = '';
|
||||
const parentInput = (
|
||||
this.previousConnection ?? this.outputConnection
|
||||
@@ -298,9 +316,7 @@ export class BlockSvg
|
||||
}
|
||||
|
||||
private computeAriaRole() {
|
||||
if (this.isSimpleReporter()) {
|
||||
aria.setRole(this.pathObject.svgPath, aria.Role.BUTTON);
|
||||
} else if (this.workspace.isFlyout) {
|
||||
if (this.workspace.isFlyout) {
|
||||
aria.setRole(this.pathObject.svgPath, aria.Role.TREEITEM);
|
||||
} else {
|
||||
aria.setState(
|
||||
@@ -335,8 +351,6 @@ export class BlockSvg
|
||||
if (!svg.parentNode) {
|
||||
this.workspace.getCanvas().appendChild(svg);
|
||||
}
|
||||
// Note: This must be done after initialization of the block's fields.
|
||||
this.recomputeAriaLabel();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -672,6 +686,7 @@ export class BlockSvg
|
||||
this.removeInput(collapsedInputName);
|
||||
dom.removeClass(this.svgGroup, 'blocklyCollapsed');
|
||||
this.setWarningText(null, BlockSvg.COLLAPSED_WARNING_ID);
|
||||
this.recomputeAriaLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -693,6 +708,8 @@ export class BlockSvg
|
||||
this.getInput(collapsedInputName) ||
|
||||
this.appendDummyInput(collapsedInputName);
|
||||
input.appendField(new FieldLabel(text), collapsedFieldName);
|
||||
|
||||
this.recomputeAriaLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1108,6 +1125,8 @@ export class BlockSvg
|
||||
for (const child of this.getChildren(false)) {
|
||||
child.updateDisabled();
|
||||
}
|
||||
|
||||
this.recomputeAriaLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1752,8 +1771,6 @@ export class BlockSvg
|
||||
* settings.
|
||||
*/
|
||||
render() {
|
||||
this.recomputeAriaLabel();
|
||||
|
||||
this.queueRender();
|
||||
renderManagement.triggerQueuedRenders();
|
||||
}
|
||||
@@ -1765,8 +1782,6 @@ export class BlockSvg
|
||||
* @internal
|
||||
*/
|
||||
renderEfficiently() {
|
||||
this.recomputeAriaLabel();
|
||||
|
||||
dom.startTextWidthCache();
|
||||
|
||||
if (this.isCollapsed()) {
|
||||
@@ -1948,6 +1963,12 @@ export class BlockSvg
|
||||
|
||||
/** See IFocusableNode.getFocusableElement. */
|
||||
getFocusableElement(): HTMLElement | SVGElement {
|
||||
if (this.isSimpleReporter()) {
|
||||
const field = Array.from(this.getFields())[0];
|
||||
if (field && field.isFullBlockField() && field.isCurrentlyEditable()) {
|
||||
return field.getFocusableElement();
|
||||
}
|
||||
}
|
||||
return this.pathObject.svgPath;
|
||||
}
|
||||
|
||||
@@ -1958,6 +1979,7 @@ export class BlockSvg
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {
|
||||
this.recomputeAriaLabel();
|
||||
this.select();
|
||||
this.workspace.scrollBoundsIntoView(
|
||||
this.getBoundingRectangleWithoutChildren(),
|
||||
@@ -2038,6 +2060,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
|
||||
return block.inputList
|
||||
.flatMap((input) => {
|
||||
const fields = input.fieldRow.map((field) => {
|
||||
if (!field.isVisible()) return [];
|
||||
// If the block is a full block field, we only want to know if it's an
|
||||
// editable field if we're not directly on it.
|
||||
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {
|
||||
@@ -2046,6 +2069,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
|
||||
return [field.getText() ?? field.getValue()];
|
||||
});
|
||||
if (
|
||||
input.isVisible() &&
|
||||
input.connection &&
|
||||
input.connection.type === ConnectionType.INPUT_VALUE
|
||||
) {
|
||||
|
||||
@@ -196,9 +196,6 @@ export abstract class Field<T = any>
|
||||
*/
|
||||
SERIALIZABLE = false;
|
||||
|
||||
/** The unique ID of this field. */
|
||||
private id_: string | null = null;
|
||||
|
||||
private config: FieldConfig | null = null;
|
||||
|
||||
/**
|
||||
@@ -272,7 +269,6 @@ export abstract class Field<T = any>
|
||||
`problems with focus: ${block.id}.`,
|
||||
);
|
||||
}
|
||||
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
|
||||
getAriaName(): string | null {
|
||||
@@ -327,11 +323,8 @@ export abstract class Field<T = any>
|
||||
// Field has already been initialized once.
|
||||
return;
|
||||
}
|
||||
const id = this.id_;
|
||||
if (!id) throw new Error('Expected ID to be defined prior to init.');
|
||||
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
|
||||
'id': id,
|
||||
});
|
||||
|
||||
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
|
||||
if (!this.isVisible()) {
|
||||
this.fieldGroup_.style.display = 'none';
|
||||
}
|
||||
@@ -343,6 +336,14 @@ export abstract class Field<T = any>
|
||||
this.bindEvents_();
|
||||
this.initModel();
|
||||
this.applyColour();
|
||||
|
||||
const id =
|
||||
this.isFullBlockField() &&
|
||||
this.isCurrentlyEditable() &&
|
||||
this.sourceBlock_?.isSimpleReporter()
|
||||
? idGenerator.getNextUniqueId()
|
||||
: `${this.sourceBlock_?.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
this.fieldGroup_.setAttribute('id', id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -202,7 +202,7 @@ export class FieldDropdown extends Field<string> {
|
||||
this.recomputeAria();
|
||||
}
|
||||
|
||||
private recomputeAria() {
|
||||
protected recomputeAria() {
|
||||
if (!this.fieldGroup_) return; // There's no element to set currently.
|
||||
const element = this.getFocusableElement();
|
||||
aria.setRole(element, aria.Role.COMBOBOX);
|
||||
@@ -213,17 +213,15 @@ export class FieldDropdown extends Field<string> {
|
||||
} else {
|
||||
aria.clearState(element, aria.State.CONTROLS);
|
||||
}
|
||||
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Dropdown');
|
||||
|
||||
// Ensure the selected item has its correct label presented since it may be
|
||||
// different than the actual text presented to the user.
|
||||
if (this.textElement_) {
|
||||
aria.setState(
|
||||
this.textElement_,
|
||||
aria.State.LABEL,
|
||||
this.computeLabelForOption(this.selectedOption),
|
||||
);
|
||||
}
|
||||
const label = [
|
||||
this.computeLabelForOption(this.selectedOption),
|
||||
this.getAriaName(),
|
||||
]
|
||||
.filter((item) => !!item)
|
||||
.join(', ');
|
||||
|
||||
aria.setState(element, aria.State.LABEL, label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,7 +643,6 @@ export class FieldDropdown extends Field<string> {
|
||||
const element = this.getFocusableElement();
|
||||
aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id);
|
||||
}
|
||||
aria.setState(textElement, aria.State.HIDDEN, false);
|
||||
|
||||
// Height and width include the border rect.
|
||||
const hasBorder = !!this.borderRect_;
|
||||
|
||||
@@ -163,11 +163,10 @@ export class FieldImage extends Field<string> {
|
||||
aria.setRole(element, aria.Role.IMAGE);
|
||||
}
|
||||
|
||||
aria.setState(
|
||||
element,
|
||||
aria.State.LABEL,
|
||||
this.altText ?? this.getAriaName(),
|
||||
);
|
||||
const label = [this.altText, this.getAriaName()]
|
||||
.filter((item) => !!item)
|
||||
.join(', ');
|
||||
aria.setState(element, aria.State.LABEL, label);
|
||||
}
|
||||
|
||||
override updateSize_() {}
|
||||
|
||||
@@ -178,13 +178,23 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
dom.addClass(this.fieldGroup_, 'blocklyInputField');
|
||||
}
|
||||
|
||||
// Showing the text-based value with the input's textbox ensures that the
|
||||
// input's value is correctly read out by screen readers with its role.
|
||||
aria.setState(this.textElement_, aria.State.HIDDEN, false);
|
||||
const element = this.getFocusableElement();
|
||||
aria.setRole(element, aria.Role.BUTTON);
|
||||
this.recomputeAriaLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ARIA label for this field.
|
||||
*/
|
||||
protected recomputeAriaLabel() {
|
||||
if (!this.fieldGroup_) return;
|
||||
|
||||
const element = this.getFocusableElement();
|
||||
aria.setRole(element, aria.Role.TEXTBOX);
|
||||
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Text');
|
||||
const label = [this.getValue(), this.getAriaName()]
|
||||
.filter((item) => !!item)
|
||||
.join(', ');
|
||||
|
||||
aria.setState(element, aria.State.LABEL, label);
|
||||
}
|
||||
|
||||
override isFullBlockField(): boolean {
|
||||
@@ -248,6 +258,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
this.isDirty_ = true;
|
||||
this.isTextValid_ = true;
|
||||
this.value_ = newValue;
|
||||
this.recomputeAriaLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -193,6 +193,9 @@ export class Input {
|
||||
child.getSvgRoot().style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
if (this.sourceBlock.rendered) {
|
||||
(this.sourceBlock as BlockSvg).recomputeAriaLabel();
|
||||
}
|
||||
return renderList;
|
||||
}
|
||||
|
||||
@@ -312,10 +315,20 @@ export class Input {
|
||||
* @internal
|
||||
* @returns A description of this input's row on its parent block.
|
||||
*/
|
||||
getFieldRowLabel() {
|
||||
return this.fieldRow.reduce((label, field) => {
|
||||
return `${label} ${field.EDITABLE ? field.getAriaName() : field.getValue()}`;
|
||||
}, '');
|
||||
getFieldRowLabel(): string {
|
||||
const fieldRowLabel = this.fieldRow
|
||||
.reduce((label, field) => {
|
||||
return `${label} ${field.getValue()}`;
|
||||
}, '')
|
||||
.trim();
|
||||
if (!fieldRowLabel) {
|
||||
const inputs = this.getSourceBlock().inputList;
|
||||
const index = inputs.indexOf(this);
|
||||
if (index > 0) {
|
||||
return inputs[index - 1].getFieldRowLabel();
|
||||
}
|
||||
}
|
||||
return fieldRowLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,10 +75,12 @@ export class MenuItem {
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'blocklyMenuItemContent';
|
||||
aria.setRole(content, aria.Role.PRESENTATION);
|
||||
// Add a checkbox for checkable menu items.
|
||||
if (this.checkable) {
|
||||
const checkbox = document.createElement('div');
|
||||
checkbox.className = 'blocklyMenuItemCheckbox ';
|
||||
aria.setRole(checkbox, aria.Role.PRESENTATION);
|
||||
content.appendChild(checkbox);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user