diff --git a/core/block_svg.ts b/core/block_svg.ts index fed2d7ea1..00b3b816d 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -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 ) { diff --git a/core/field.ts b/core/field.ts index 79fd8c9f0..234308d69 100644 --- a/core/field.ts +++ b/core/field.ts @@ -196,9 +196,6 @@ export abstract class Field */ 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 `problems with focus: ${block.id}.`, ); } - this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } getAriaName(): string | null { @@ -327,11 +323,8 @@ export abstract class Field // 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 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); } /** diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 7b6e8d5e8..12ef0c27f 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -202,7 +202,7 @@ export class FieldDropdown extends Field { 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 { } 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 { 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_; diff --git a/core/field_image.ts b/core/field_image.ts index ae66eae3d..91e6dccdc 100644 --- a/core/field_image.ts +++ b/core/field_image.ts @@ -163,11 +163,10 @@ export class FieldImage extends Field { 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_() {} diff --git a/core/field_input.ts b/core/field_input.ts index 216dad113..5d4d3bdb3 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -178,13 +178,23 @@ export abstract class FieldInput 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 extends Field< this.isDirty_ = true; this.isTextValid_ = true; this.value_ = newValue; + this.recomputeAriaLabel(); } /** diff --git a/core/inputs/input.ts b/core/inputs/input.ts index c6f75712a..5195e5d68 100644 --- a/core/inputs/input.ts +++ b/core/inputs/input.ts @@ -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; } /** diff --git a/core/menuitem.ts b/core/menuitem.ts index 2a36620ee..aada3aa0a 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -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); }