diff --git a/packages/blockly/core/field.ts b/packages/blockly/core/field.ts index e025efab7..2ecef1681 100644 --- a/packages/blockly/core/field.ts +++ b/packages/blockly/core/field.ts @@ -98,6 +98,9 @@ export abstract class Field /** Validation function called when user edits an editable field. */ protected validator_: FieldValidator | 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 if (config.tooltip) { this.setTooltip(parsing.replaceMessageReferences(config.tooltip)); } + if (config.ariaTypeName) { + this.ariaTypeName = config.ariaTypeName; + } } /** @@ -300,6 +306,88 @@ export abstract class Field 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 */ export interface FieldConfig { tooltip?: string; + ariaTypeName?: string; } /** diff --git a/packages/blockly/tests/mocha/field_test.js b/packages/blockly/tests/mocha/field_test.js index 422b04734..e2163a2a3 100644 --- a/packages/blockly/tests/mocha/field_test.js +++ b/packages/blockly/tests/mocha/field_test.js @@ -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'); + }); + }); + }); });