mirror of
https://github.com/google/blockly.git
synced 2026-04-30 00:50:12 +02:00
feat: FieldInput ARIA (#9744)
* feat: `FieldInput' ARIA * chore: update tsdocs * chore: lint fix * fix: use aria util for setting role * fix: use single empty field message
This commit is contained in:
@@ -60,7 +60,7 @@ export function computeAriaLabel(
|
||||
return [
|
||||
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
|
||||
getParentInputLabel(block),
|
||||
...getInputLabels(block),
|
||||
...getInputLabels(block, verbosity),
|
||||
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
|
||||
@@ -111,15 +111,17 @@ export function configureAriaRole(block: BlockSvg) {
|
||||
export function computeFieldRowLabel(
|
||||
input: Input,
|
||||
lookback: boolean,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
): string[] {
|
||||
const includeTypeInfo = verbosity >= Verbosity.STANDARD;
|
||||
const fieldRowLabel = input.fieldRow
|
||||
.filter((field) => field.isVisible())
|
||||
.map((field) => field.computeAriaLabel(true));
|
||||
.map((field) => field.computeAriaLabel(includeTypeInfo));
|
||||
if (!fieldRowLabel.length && lookback) {
|
||||
const inputs = input.getSourceBlock().inputList;
|
||||
const index = inputs.indexOf(input);
|
||||
if (index > 0) {
|
||||
return computeFieldRowLabel(inputs[index - 1], lookback);
|
||||
return computeFieldRowLabel(inputs[index - 1], lookback, verbosity);
|
||||
}
|
||||
}
|
||||
return fieldRowLabel;
|
||||
@@ -186,10 +188,13 @@ function getBeginStackLabel(block: BlockSvg) {
|
||||
* @param block The block to retrieve a list of field/input labels for.
|
||||
* @returns A list of field/input labels for the given block.
|
||||
*/
|
||||
export function getInputLabels(block: BlockSvg): string[] {
|
||||
export function getInputLabels(
|
||||
block: BlockSvg,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
): string[] {
|
||||
return block.inputList
|
||||
.filter((input) => input.isVisible())
|
||||
.map((input) => input.getLabel());
|
||||
.map((input) => input.getLabel(verbosity));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +213,11 @@ export function getInputLabels(block: BlockSvg): string[] {
|
||||
* @param input The input that defines the end of the subset.
|
||||
* @returns A list of field/input labels for the given block.
|
||||
*/
|
||||
export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
|
||||
export function getInputLabelsSubset(
|
||||
block: BlockSvg,
|
||||
input: Input,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
): string[] {
|
||||
const inputIndex = block.inputList.indexOf(input);
|
||||
if (inputIndex === -1) {
|
||||
throw new Error(
|
||||
@@ -226,7 +235,7 @@ export function getInputLabelsSubset(block: BlockSvg, input: Input): string[] {
|
||||
.filter((input) => input.isVisible())
|
||||
.map(
|
||||
(input) =>
|
||||
input.getLabel() ||
|
||||
input.getLabel(verbosity) ||
|
||||
Msg['INPUT_LABEL_INDEX'].replace(
|
||||
'%1',
|
||||
(input.getIndex() + 1).toString(),
|
||||
|
||||
@@ -175,6 +175,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
if (this.fieldGroup_) {
|
||||
dom.addClass(this.fieldGroup_, 'blocklyInputField');
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
override isFullBlockField(): boolean {
|
||||
@@ -224,6 +225,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
);
|
||||
}
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +240,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
this.isDirty_ = true;
|
||||
this.isTextValid_ = true;
|
||||
this.value_ = newValue;
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,6 +810,57 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
protected getValueFromEditorText_(text: string): AnyDuringMigration {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text.
|
||||
*/
|
||||
override getAriaValue(): string | null {
|
||||
return this.getText() || Msg['FIELD_LABEL_EMPTY'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the ARIA role and label for this field.
|
||||
*/
|
||||
private recomputeAriaContext(): void {
|
||||
const focusableElement = this.getClickTarget_();
|
||||
if (!focusableElement) return;
|
||||
|
||||
if (this.getSourceBlock()?.isInFlyout) {
|
||||
aria.setState(focusableElement, aria.State.HIDDEN, true);
|
||||
return;
|
||||
}
|
||||
|
||||
aria.setState(focusableElement, aria.State.HIDDEN, false);
|
||||
// The button role is intended to indicate to users that the field has an
|
||||
// editing mode that can be activated.
|
||||
aria.setRole(focusableElement, aria.Role.BUTTON);
|
||||
|
||||
let label = this.computeAriaLabel(false);
|
||||
|
||||
if (this.isCurrentlyEditable?.()) {
|
||||
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
|
||||
}
|
||||
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import {ConnectionType} from '../connection_type.js';
|
||||
import type {Field} from '../field.js';
|
||||
import * as fieldRegistry from '../field_registry.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {Verbosity} from '../utils/aria.js';
|
||||
import {Align} from './align.js';
|
||||
import {inputTypes} from './input_types.js';
|
||||
|
||||
@@ -356,15 +357,17 @@ export class Input {
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getLabel(): string {
|
||||
getLabel(verbosity = Verbosity.STANDARD): string {
|
||||
if (!this.isVisible()) return '';
|
||||
|
||||
const labels = computeFieldRowLabel(this, false);
|
||||
const labels = computeFieldRowLabel(this, false, verbosity);
|
||||
|
||||
if (this.connection?.type === ConnectionType.INPUT_VALUE) {
|
||||
const childBlock = this.connection.targetBlock();
|
||||
if (childBlock && !childBlock.isInsertionMarker()) {
|
||||
labels.push(getInputLabels(childBlock as BlockSvg).join(' '));
|
||||
labels.push(
|
||||
getInputLabels(childBlock as BlockSvg, verbosity).join(' '),
|
||||
);
|
||||
}
|
||||
}
|
||||
return labels.join(' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@metadata": {
|
||||
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
|
||||
"lastupdated": "2026-04-21 12:36:42.927221",
|
||||
"lastupdated": "2026-04-21 16:21:15.987859",
|
||||
"locale": "en",
|
||||
"messagedocumentation" : "qqq"
|
||||
},
|
||||
@@ -468,5 +468,7 @@
|
||||
"ANNOUNCE_MOVE_AROUND": "moving %1 %2 around %3",
|
||||
"ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4",
|
||||
"ANNOUNCE_MOVE_CANCELED": "Canceled movement",
|
||||
"FIELD_LABEL_EMPTY": "empty"
|
||||
"FIELD_LABEL_EMPTY": "empty",
|
||||
"ARIA_TYPE_FIELD_INPUT": "input field",
|
||||
"FIELD_LABEL_EDIT_PREFIX": "Edit %1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"@metadata": {
|
||||
"@metadata": {
|
||||
"authors": [
|
||||
"Ajeje Brazorf",
|
||||
"Amire80",
|
||||
@@ -475,5 +475,7 @@
|
||||
"ANNOUNCE_MOVE_AROUND": "ARIA live region message announcing a block is being moved around another block, optionally including connection-specific label for disambiguation. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block \n\nExamples:\n* 'moving around print abc'\n* 'moving if, do else statement around print abc'",
|
||||
"ANNOUNCE_MOVE_TO": "ARIA live region message announcing a block is being moved to a workspace location where the relationship is not specifically known. \n\nParameters:\n* %1 - optional phrase describing the moving stack of blocks \n* %2 - optional phrase describing the local connection label \n* %3 - the label of the target (neighbour) block or location \n* %4 - optional phrase describing the target connection label \n\nExamples:\n* 'moving to repeat 10, times, do'\n* 'moving 2 stack blocks else statement to repeat 10, times, do previous connection'",
|
||||
"ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.",
|
||||
"FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content."
|
||||
"FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.",
|
||||
"ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input field, used by screen readers to identify the type of field.",
|
||||
"FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'"
|
||||
}
|
||||
|
||||
@@ -1881,4 +1881,12 @@ Blockly.Msg.ANNOUNCE_MOVE_TO = 'moving %1 %2 to %3 %4';
|
||||
Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement';
|
||||
/** @type {string} */
|
||||
/// Label for an empty field, used by screen readers to identify fields that have no content.
|
||||
Blockly.Msg.FIELD_LABEL_EMPTY = 'empty';
|
||||
Blockly.Msg.FIELD_LABEL_EMPTY = 'empty';
|
||||
/** @type {string} */
|
||||
/// ARIA type name for an input field, used by screen readers to identify the type of field.
|
||||
Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field';
|
||||
/** @type {string} */
|
||||
/// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value.
|
||||
/// \n\nParameters:\n* %1 - the label of the field's value
|
||||
/// \n\nExamples:\n* "Edit 5"\n* "Edit item"
|
||||
Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1';
|
||||
@@ -502,4 +502,50 @@ suite('Number Fields', function () {
|
||||
this.assertValue(1.7976931348623157e308);
|
||||
});
|
||||
});
|
||||
suite('ARIA', function () {
|
||||
setup(function () {
|
||||
this.workspace = Blockly.inject('blocklyDiv', {
|
||||
renderer: 'geras',
|
||||
});
|
||||
const block = this.workspace.newBlock('math_number');
|
||||
this.field = block.getField('NUM');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
|
||||
this.focusableElement = this.field.getClickTarget_();
|
||||
});
|
||||
test('Focusable element has role of button', function () {
|
||||
const role = this.focusableElement.getAttribute('role');
|
||||
assert.equal(role, 'button');
|
||||
});
|
||||
test('Hidden when in a flyout', function () {
|
||||
this.field.getSourceBlock().isInFlyout = true;
|
||||
// Force recompute of ARIA label.
|
||||
this.field.setValue(this.field.getValue());
|
||||
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
|
||||
assert.equal(ariaHidden, 'true');
|
||||
});
|
||||
test('Has an ARIA label by default', function () {
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(label.includes('0'));
|
||||
});
|
||||
test('Has Edit prefix if editable', function () {
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(label.includes('Edit'));
|
||||
});
|
||||
test('Does not have Edit prefix if not editable', function () {
|
||||
this.field.EDITABLE = false;
|
||||
// Force recompute of ARIA label.
|
||||
this.field.setValue(this.field.getValue());
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isFalse(label.includes('Edit'));
|
||||
});
|
||||
test('setValue updates ARIA label', function () {
|
||||
const initialLabel = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(initialLabel.includes('0'));
|
||||
this.field.setValue(1);
|
||||
const updatedLabel = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(updatedLabel.includes('1'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -592,4 +592,51 @@ suite('Text Input Fields', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('ARIA', function () {
|
||||
setup(function () {
|
||||
this.workspace = Blockly.inject('blocklyDiv', {
|
||||
renderer: 'geras',
|
||||
});
|
||||
const block = this.workspace.newBlock('text');
|
||||
this.field = block.getField('TEXT');
|
||||
block.initSvg();
|
||||
block.render();
|
||||
|
||||
this.focusableElement = this.field.getClickTarget_();
|
||||
});
|
||||
test('Focusable element has role of button', function () {
|
||||
const role = this.focusableElement.getAttribute('role');
|
||||
assert.equal(role, 'button');
|
||||
});
|
||||
test('Hidden when in a flyout', function () {
|
||||
this.field.getSourceBlock().isInFlyout = true;
|
||||
// Force recompute of ARIA label.
|
||||
this.field.setValue(this.field.getValue());
|
||||
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
|
||||
assert.equal(ariaHidden, 'true');
|
||||
});
|
||||
test('Has placeholder ARIA label by default', function () {
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(label.includes('empty'));
|
||||
});
|
||||
test('Has Edit prefix if editable', function () {
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(label.includes('Edit'));
|
||||
});
|
||||
test('Does not have Edit prefix if not editable', function () {
|
||||
this.field.EDITABLE = false;
|
||||
// Force recompute of ARIA label.
|
||||
this.field.setValue(this.field.getValue());
|
||||
const label = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isFalse(label.includes('Edit'));
|
||||
});
|
||||
test('setValue updates ARIA label', function () {
|
||||
const initialLabel = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(initialLabel.includes('empty'));
|
||||
this.field.setValue('new value');
|
||||
const updatedLabel = this.focusableElement.getAttribute('aria-label');
|
||||
assert.isTrue(updatedLabel.includes('new value'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user