feat: Add aria APIs to Field base class (#9683)

* feat: Add aria APIs to Field base class

* fix: no underscores in new code
This commit is contained in:
Michael Harvey
2026-04-08 10:00:36 -04:00
committed by GitHub
parent cb0d1c96ce
commit 9d5307cc37
2 changed files with 216 additions and 0 deletions
+89
View File
@@ -98,6 +98,9 @@ export abstract class Field<T = any>
/** Validation function called when user edits an editable field. */
protected validator_: FieldValidator<T> | null = null;
/** The ARIA-friendly label representation of this field's type. */
protected ariaTypeName: string | null = null;
/**
* Used to cache the field's tooltip value if setTooltip is called when the
* field is not yet initialized. Is *not* guaranteed to be accurate.
@@ -250,6 +253,9 @@ export abstract class Field<T = any>
if (config.tooltip) {
this.setTooltip(parsing.replaceMessageReferences(config.tooltip));
}
if (config.ariaTypeName) {
this.ariaTypeName = config.ariaTypeName;
}
}
/**
@@ -300,6 +306,88 @@ export abstract class Field<T = any>
return this.sourceBlock_;
}
/**
* 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 null if it is
* unspecified.
*/
getAriaTypeName(): string | null {
return this.ariaTypeName;
}
/**
* Gets an ARIA-friendly label representation of this field's value.
*
* Note that implementations should generally always override this value to
* ensure a non-null value is returned since the default implementation relies
* on 'getValue' which may return null, and a null return value for this
* function will prompt ARIA label generation to skip the field's value
* entirely when there may be a better contextual placeholder to use, instead,
* specific to the field.
*
* 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 value, or null if no value
* is currently defined or known for the field.
*/
getAriaValue(): string | null {
const value = this.getValue();
if (value === null || value === undefined) {
return null;
}
return String(value);
}
/**
* Computes a descriptive ARIA label to represent this field with configurable
* verbosity.
*
* A 'verbose' label includes type information, if available, whereas a
* non-verbose label only contains the field's value.
*
* Note that this will always return the latest representation of the field's
* label which may differ from any previously set ARIA label for the field
* itself. Implementations are largely responsible for ensuring that the
* field's ARIA label is set correctly at relevant moments in the field's
* lifecycle (such as when its value changes).
*
* Finally, it is never guaranteed that implementations use the label returned
* by this method for their actual ARIA label. Some implementations may rely
* on other contexts to convey information like the field's value. Example:
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* It's possible this returns an empty string if the field doesn't supply type
* or value information for certain cases (such as a null value). This can
* lead to the field being potentially COMPLETELY HIDDEN for screen reader
* navigation so it's crucial for implementations to ensure a non-empty value
* is returned here.
*
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
computeAriaLabel(includeTypeInfo: boolean = false): string {
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
const ariaValue = this.getAriaValue();
if (!ariaTypeName && !ariaValue) {
return '';
}
if (ariaTypeName && ariaValue) {
return `${ariaTypeName}: ${ariaValue}`;
}
return ariaTypeName ?? ariaValue ?? '';
}
/**
* Initialize everything to render this field. Override
* methods initModel and initView rather than this method.
@@ -1417,6 +1505,7 @@ export abstract class Field<T = any>
*/
export interface FieldConfig {
tooltip?: string;
ariaTypeName?: string;
}
/**
+127
View File
@@ -818,4 +818,131 @@ suite('Abstract Fields', function () {
});
});
});
suite('Aria', function () {
class TestField extends Blockly.Field {
constructor(value, config = undefined) {
super(value, null, config);
}
}
suite('getAriaTypeName', function () {
test('Default returns null', function () {
const field = new TestField();
assert.isNull(field.getAriaTypeName());
});
test('Returns configured ariaTypeName (JS)', function () {
const field = new TestField('value', {ariaTypeName: 'number'});
assert.equal(field.getAriaTypeName(), 'number');
});
test('Returns configured ariaTypeName (JSON)', function () {
class CustomField extends Blockly.Field {
constructor(opt_config) {
super('value', null, opt_config);
}
static fromJson(options) {
return new CustomField(options);
}
}
const field = CustomField.fromJson({ariaTypeName: 'text input'});
assert.equal(field.getAriaTypeName(), 'text input');
});
});
suite('getAriaValue', function () {
test('Returns string value', function () {
const field = new TestField('hello');
assert.equal(field.getAriaValue(), 'hello');
});
test('Returns stringified number', function () {
const field = new TestField(123);
assert.equal(field.getAriaValue(), '123');
});
test('Returns null for null value', function () {
const field = new TestField(null);
assert.isNull(field.getAriaValue());
});
test('Returns null for undefined value', function () {
const field = new TestField(undefined);
assert.isNull(field.getAriaValue());
});
});
suite('computeAriaLabel', function () {
test('Value only (default)', function () {
const field = new TestField('hello');
assert.equal(field.computeAriaLabel(), 'hello');
});
test('Value only when includeTypeInfo=false', function () {
const field = new TestField('hello', {ariaTypeName: 'text'});
assert.equal(field.computeAriaLabel(false), 'hello');
});
test('Type and value when includeTypeInfo=true', function () {
const field = new TestField('hello', {ariaTypeName: 'text'});
assert.equal(field.computeAriaLabel(true), 'text: hello');
});
test('Type only when value is null', function () {
const field = new TestField(null, {ariaTypeName: 'text'});
assert.equal(field.computeAriaLabel(true), 'text');
});
test('Empty string when no type or value', function () {
const field = new TestField(null);
assert.equal(field.computeAriaLabel(true), '');
});
test('Handles missing type with includeTypeInfo=true', function () {
const field = new TestField('hello');
assert.equal(field.computeAriaLabel(true), 'hello');
});
});
suite('Subclass overrides', function () {
class CustomValueField extends TestField {
getAriaValue() {
return 'custom value';
}
}
class CustomTypeField extends TestField {
getAriaTypeName() {
return 'custom type';
}
}
class FullCustomField extends TestField {
getAriaValue() {
return 'custom value';
}
getAriaTypeName() {
return 'custom type';
}
}
test('Uses overridden getAriaValue', function () {
const field = new CustomValueField('ignored');
assert.equal(field.computeAriaLabel(), 'custom value');
});
test('Uses overridden getAriaTypeName', function () {
const field = new CustomTypeField('value');
assert.equal(field.computeAriaLabel(true), 'custom type: value');
});
test('Uses both overrides', function () {
const field = new FullCustomField();
assert.equal(field.computeAriaLabel(true), 'custom type: custom value');
});
});
});
});