mirror of
https://github.com/google/blockly.git
synced 2026-04-26 07:00:23 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user