Skip to content

Commit 9d5307c

Browse files
authored
feat: Add aria APIs to Field base class (#9683)
* feat: Add aria APIs to Field base class * fix: no underscores in new code
1 parent cb0d1c9 commit 9d5307c

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

packages/blockly/core/field.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export abstract class Field<T = any>
9898
/** Validation function called when user edits an editable field. */
9999
protected validator_: FieldValidator<T> | null = null;
100100

101+
/** The ARIA-friendly label representation of this field's type. */
102+
protected ariaTypeName: string | null = null;
103+
101104
/**
102105
* Used to cache the field's tooltip value if setTooltip is called when the
103106
* field is not yet initialized. Is *not* guaranteed to be accurate.
@@ -250,6 +253,9 @@ export abstract class Field<T = any>
250253
if (config.tooltip) {
251254
this.setTooltip(parsing.replaceMessageReferences(config.tooltip));
252255
}
256+
if (config.ariaTypeName) {
257+
this.ariaTypeName = config.ariaTypeName;
258+
}
253259
}
254260

255261
/**
@@ -300,6 +306,88 @@ export abstract class Field<T = any>
300306
return this.sourceBlock_;
301307
}
302308

309+
/**
310+
* Gets an ARIA-friendly label representation of this field's type.
311+
*
312+
* Implementations are responsible for, and encouraged to, return a localized
313+
* version of the ARIA representation of the field's type.
314+
*
315+
* @returns An ARIA representation of the field's type or null if it is
316+
* unspecified.
317+
*/
318+
getAriaTypeName(): string | null {
319+
return this.ariaTypeName;
320+
}
321+
322+
/**
323+
* Gets an ARIA-friendly label representation of this field's value.
324+
*
325+
* Note that implementations should generally always override this value to
326+
* ensure a non-null value is returned since the default implementation relies
327+
* on 'getValue' which may return null, and a null return value for this
328+
* function will prompt ARIA label generation to skip the field's value
329+
* entirely when there may be a better contextual placeholder to use, instead,
330+
* specific to the field.
331+
*
332+
* Implementations are responsible for, and encouraged to, return a localized
333+
* version of the ARIA representation of the field's value.
334+
*
335+
* @returns An ARIA representation of the field's value, or null if no value
336+
* is currently defined or known for the field.
337+
*/
338+
getAriaValue(): string | null {
339+
const value = this.getValue();
340+
341+
if (value === null || value === undefined) {
342+
return null;
343+
}
344+
345+
return String(value);
346+
}
347+
348+
/**
349+
* Computes a descriptive ARIA label to represent this field with configurable
350+
* verbosity.
351+
*
352+
* A 'verbose' label includes type information, if available, whereas a
353+
* non-verbose label only contains the field's value.
354+
*
355+
* Note that this will always return the latest representation of the field's
356+
* label which may differ from any previously set ARIA label for the field
357+
* itself. Implementations are largely responsible for ensuring that the
358+
* field's ARIA label is set correctly at relevant moments in the field's
359+
* lifecycle (such as when its value changes).
360+
*
361+
* Finally, it is never guaranteed that implementations use the label returned
362+
* by this method for their actual ARIA label. Some implementations may rely
363+
* on other contexts to convey information like the field's value. Example:
364+
* checkboxes represent their checked/non-checked status (i.e. value) through
365+
* a separate ARIA property.
366+
*
367+
* It's possible this returns an empty string if the field doesn't supply type
368+
* or value information for certain cases (such as a null value). This can
369+
* lead to the field being potentially COMPLETELY HIDDEN for screen reader
370+
* navigation so it's crucial for implementations to ensure a non-empty value
371+
* is returned here.
372+
*
373+
* @param includeTypeInfo Whether to include the field's type information in
374+
* the returned label, if available.
375+
*/
376+
computeAriaLabel(includeTypeInfo: boolean = false): string {
377+
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
378+
const ariaValue = this.getAriaValue();
379+
380+
if (!ariaTypeName && !ariaValue) {
381+
return '';
382+
}
383+
384+
if (ariaTypeName && ariaValue) {
385+
return `${ariaTypeName}: ${ariaValue}`;
386+
}
387+
388+
return ariaTypeName ?? ariaValue ?? '';
389+
}
390+
303391
/**
304392
* Initialize everything to render this field. Override
305393
* methods initModel and initView rather than this method.
@@ -1417,6 +1505,7 @@ export abstract class Field<T = any>
14171505
*/
14181506
export interface FieldConfig {
14191507
tooltip?: string;
1508+
ariaTypeName?: string;
14201509
}
14211510

14221511
/**

packages/blockly/tests/mocha/field_test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,4 +818,131 @@ suite('Abstract Fields', function () {
818818
});
819819
});
820820
});
821+
822+
suite('Aria', function () {
823+
class TestField extends Blockly.Field {
824+
constructor(value, config = undefined) {
825+
super(value, null, config);
826+
}
827+
}
828+
829+
suite('getAriaTypeName', function () {
830+
test('Default returns null', function () {
831+
const field = new TestField();
832+
assert.isNull(field.getAriaTypeName());
833+
});
834+
835+
test('Returns configured ariaTypeName (JS)', function () {
836+
const field = new TestField('value', {ariaTypeName: 'number'});
837+
assert.equal(field.getAriaTypeName(), 'number');
838+
});
839+
840+
test('Returns configured ariaTypeName (JSON)', function () {
841+
class CustomField extends Blockly.Field {
842+
constructor(opt_config) {
843+
super('value', null, opt_config);
844+
}
845+
846+
static fromJson(options) {
847+
return new CustomField(options);
848+
}
849+
}
850+
851+
const field = CustomField.fromJson({ariaTypeName: 'text input'});
852+
assert.equal(field.getAriaTypeName(), 'text input');
853+
});
854+
});
855+
856+
suite('getAriaValue', function () {
857+
test('Returns string value', function () {
858+
const field = new TestField('hello');
859+
assert.equal(field.getAriaValue(), 'hello');
860+
});
861+
862+
test('Returns stringified number', function () {
863+
const field = new TestField(123);
864+
assert.equal(field.getAriaValue(), '123');
865+
});
866+
867+
test('Returns null for null value', function () {
868+
const field = new TestField(null);
869+
assert.isNull(field.getAriaValue());
870+
});
871+
872+
test('Returns null for undefined value', function () {
873+
const field = new TestField(undefined);
874+
assert.isNull(field.getAriaValue());
875+
});
876+
});
877+
878+
suite('computeAriaLabel', function () {
879+
test('Value only (default)', function () {
880+
const field = new TestField('hello');
881+
assert.equal(field.computeAriaLabel(), 'hello');
882+
});
883+
884+
test('Value only when includeTypeInfo=false', function () {
885+
const field = new TestField('hello', {ariaTypeName: 'text'});
886+
assert.equal(field.computeAriaLabel(false), 'hello');
887+
});
888+
889+
test('Type and value when includeTypeInfo=true', function () {
890+
const field = new TestField('hello', {ariaTypeName: 'text'});
891+
assert.equal(field.computeAriaLabel(true), 'text: hello');
892+
});
893+
894+
test('Type only when value is null', function () {
895+
const field = new TestField(null, {ariaTypeName: 'text'});
896+
assert.equal(field.computeAriaLabel(true), 'text');
897+
});
898+
899+
test('Empty string when no type or value', function () {
900+
const field = new TestField(null);
901+
assert.equal(field.computeAriaLabel(true), '');
902+
});
903+
904+
test('Handles missing type with includeTypeInfo=true', function () {
905+
const field = new TestField('hello');
906+
assert.equal(field.computeAriaLabel(true), 'hello');
907+
});
908+
});
909+
910+
suite('Subclass overrides', function () {
911+
class CustomValueField extends TestField {
912+
getAriaValue() {
913+
return 'custom value';
914+
}
915+
}
916+
917+
class CustomTypeField extends TestField {
918+
getAriaTypeName() {
919+
return 'custom type';
920+
}
921+
}
922+
923+
class FullCustomField extends TestField {
924+
getAriaValue() {
925+
return 'custom value';
926+
}
927+
getAriaTypeName() {
928+
return 'custom type';
929+
}
930+
}
931+
932+
test('Uses overridden getAriaValue', function () {
933+
const field = new CustomValueField('ignored');
934+
assert.equal(field.computeAriaLabel(), 'custom value');
935+
});
936+
937+
test('Uses overridden getAriaTypeName', function () {
938+
const field = new CustomTypeField('value');
939+
assert.equal(field.computeAriaLabel(true), 'custom type: value');
940+
});
941+
942+
test('Uses both overrides', function () {
943+
const field = new FullCustomField();
944+
assert.equal(field.computeAriaLabel(true), 'custom type: custom value');
945+
});
946+
});
947+
});
821948
});

0 commit comments

Comments
 (0)