feat: ARIA for other field classes (#9772)

* feat: ARIA for other field classes

* fix: code review
This commit is contained in:
Michael Harvey
2026-04-29 12:18:03 -04:00
committed by GitHub
parent f8ba2016af
commit 86fa331867
20 changed files with 391 additions and 29 deletions
+2 -2
View File
@@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
this.type === 'variables_get' ||
this.type === 'variables_get_reporter'
) {
const name = this.getField('VAR')!.getText();
const renameOption = {
text: Msg['RENAME_VARIABLE'],
text: Msg['RENAME_VARIABLE'].replace('%1', name),
enabled: true,
callback: renameOptionCallbackFactory(this),
};
const name = this.getField('VAR')!.getText();
const deleteOption = {
text: Msg['DELETE_VARIABLE'].replace('%1', name),
enabled: true,
+2 -2
View File
@@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
this.type === 'variables_get_dynamic' ||
this.type === 'variables_get_reporter_dynamic'
) {
const name = this.getField('VAR')!.getText();
const renameOption = {
text: Msg['RENAME_VARIABLE'],
text: Msg['RENAME_VARIABLE'].replace('%1', name),
enabled: true,
callback: renameOptionCallbackFactory(this),
};
const name = this.getField('VAR')!.getText();
const deleteOption = {
text: Msg['DELETE_VARIABLE'].replace('%1', name),
enabled: true,
+1 -1
View File
@@ -373,7 +373,7 @@ export abstract class Field<T = any>
* @param includeTypeInfo Whether to include the field's type information in
* the returned label, if available.
*/
computeAriaLabel(includeTypeInfo: boolean = false): string {
computeAriaLabel(includeTypeInfo: boolean = true): string {
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
let ariaValue = this.getAriaValue();
if (ariaValue === null || ariaValue === '') {
+62
View File
@@ -16,6 +16,8 @@ import './events/events_block_change.js';
import {Field, FieldConfig, FieldValidator} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
type BoolString = 'TRUE' | 'FALSE';
@@ -111,6 +113,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
const textElement = this.getTextElement();
dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
textElement.style.display = this.value_ ? 'block' : 'none';
this.recomputeAriaContext();
}
override render_() {
@@ -170,6 +173,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
if (this.textElement_) {
this.textElement_.style.display = this.value_ ? 'block' : 'none';
}
this.recomputeAriaContext();
}
/**
@@ -213,6 +217,39 @@ export class FieldCheckbox extends Field<CheckboxBool> {
return !!value;
}
/**
* 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 {
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_CHECKBOX'];
}
/**
* 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.
*
* The FieldCheckbox implementation is not used for the actual ARIA label of
* the field, since the checked state is already included in the ARIA checked
* state, but it is used for the ARIA label of its source block.
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string | null {
// return null;
const checked = this.convertValueToBool(this.value_);
return checked
? Msg['FIELD_LABEL_CHECKBOX_CHECKED']
: Msg['FIELD_LABEL_CHECKBOX_UNCHECKED'];
}
/**
* Construct a FieldCheckbox from a JSON arg object.
*
@@ -228,6 +265,31 @@ export class FieldCheckbox extends Field<CheckboxBool> {
// 'override' the static fromJson method.
return new this(options.checked, undefined, options);
}
/**
* Recomputes the ARIA role and label for this field.
*/
protected 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);
aria.setRole(focusableElement, aria.Role.CHECKBOX);
const checked = this.convertValueToBool(this.value_);
aria.setState(focusableElement, aria.State.CHECKED, checked);
// Checkbox fields do not use this.computeAriaLabel(), because the
// included 'checked' or 'not checked' state in the ARIA label would
// be redundant with the ARIA checked state.
const label = this.getAriaTypeName();
aria.setState(focusableElement, aria.State.LABEL, label);
}
}
fieldRegistry.register('field_checkbox', FieldCheckbox);
+3 -3
View File
@@ -884,7 +884,7 @@ export class FieldDropdown extends Field<string> {
*
* @returns An ARIA representation of the field's text.
*/
override getAriaValue(): string | null {
override getAriaValue(): string {
// Note: This fallback is effectively unreachable since computeOptionAriaLabel
// always returns a non-empty string for non-separator options. It exists as a
// defensive safeguard.
@@ -920,7 +920,7 @@ export class FieldDropdown extends Field<string> {
/**
* Recomputes the ARIA role and label for this field.
*/
private recomputeAriaContext(): void {
protected recomputeAriaContext(): void {
const focusableElement = this.getFocusableElement();
if (!focusableElement) return;
@@ -934,7 +934,7 @@ export class FieldDropdown extends Field<string> {
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
const label = this.computeAriaLabel(false);
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
+58
View File
@@ -13,6 +13,8 @@
import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
@@ -157,6 +159,8 @@ export class FieldImage extends Field<string> {
if (this.clickHandler) {
this.imageElement.style.cursor = 'pointer';
}
this.recomputeAriaContext();
}
override updateSize_() {}
@@ -186,6 +190,7 @@ export class FieldImage extends Field<string> {
if (this.imageElement) {
this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_);
}
this.recomputeAriaContext();
}
/**
@@ -283,6 +288,59 @@ export class FieldImage extends Field<string> {
options,
);
}
/**
* 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_IMAGE'];
}
/**
* 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, or null if no text is
* currently defined or known for the field.
*/
override getAriaValue(): string | null {
return this.altText || null;
}
/**
* Recomputes the ARIA role and label for this field.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
const isInFlyout = this.getSourceBlock()?.isInFlyout;
if (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. The presentation role is used to
// prevent screen readers from reading the content or its descendants.
// Only clickable image fields are navigable.
aria.setRole(
focusableElement,
this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION,
);
const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
}
}
fieldRegistry.register('field_image', FieldImage);
+2 -2
View File
@@ -839,7 +839,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
/**
* Recomputes the ARIA role and label for this field.
*/
private recomputeAriaContext(): void {
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
@@ -853,7 +853,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
let label = this.computeAriaLabel(false);
let label = this.computeAriaLabel(true);
if (this.isCurrentlyEditable?.()) {
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
+2
View File
@@ -14,6 +14,7 @@
import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import {aria} from './utils.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
@@ -76,6 +77,7 @@ export class FieldLabel extends Field<string> {
}
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyLabelField');
aria.setState(this.fieldGroup_, aria.State.HIDDEN, true);
}
}
-3
View File
@@ -19,7 +19,6 @@ import {
} from './field_input.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
/**
@@ -300,11 +299,9 @@ export class FieldNumber extends FieldInput<number> {
// Set the accessibility state
if (this.min_ > -Infinity) {
htmlInput.min = `${this.min_}`;
aria.setState(htmlInput, aria.State.VALUEMIN, this.min_);
}
if (this.max_ < Infinity) {
htmlInput.max = `${this.max_}`;
aria.setState(htmlInput, aria.State.VALUEMAX, this.max_);
}
return htmlInput;
}
+13 -1
View File
@@ -605,7 +605,7 @@ export class FieldVariable extends FieldDropdown {
];
}
options.push([
Msg['RENAME_VARIABLE'],
Msg['RENAME_VARIABLE'].replace('%1', name),
internalConstants.RENAME_VARIABLE_ID,
]);
if (Msg['DELETE_VARIABLE']) {
@@ -617,6 +617,18 @@ export class FieldVariable extends FieldDropdown {
return options;
}
/**
* 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 {
// Example: 'Variable "i"'
return Msg['FIELD_LABEL_VARIABLE'].replace('%1', super.getAriaValue());
}
}
fieldRegistry.register('field_variable', FieldVariable);
+8 -3
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-24 15:03:55.288228",
"lastupdated": "2026-04-29 08:57:47.670420",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -29,7 +29,7 @@
"UNDO": "Undo",
"REDO": "Redo",
"CHANGE_VALUE_TITLE": "Change value:",
"RENAME_VARIABLE": "Rename variable...",
"RENAME_VARIABLE": "Rename the '%1' variable",
"RENAME_VARIABLE_TITLE": "Rename all '%1' variables to:",
"NEW_VARIABLE": "Create variable...",
"NEW_STRING_VARIABLE": "Create string variable...",
@@ -488,6 +488,11 @@
"ARIA_TYPE_FIELD_TEXT_INPUT": "text",
"ARIA_TYPE_FIELD_NUMBER": "number",
"ARIA_TYPE_FIELD_DROPDOWN": "dropdown",
"ARIA_TYPE_FIELD_IMAGE": "image",
"ARIA_TYPE_FIELD_CHECKBOX": "checkbox",
"FIELD_LABEL_EDIT_PREFIX": "Edit %1",
"FIELD_LABEL_OPTION_INDEX": "Option %1"
"FIELD_LABEL_OPTION_INDEX": "Option %1",
"FIELD_LABEL_CHECKBOX_CHECKED": "Checked",
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Not checked",
"FIELD_LABEL_VARIABLE": "Variable '%1'"
}
+6 -1
View File
@@ -495,6 +495,11 @@
"ARIA_TYPE_FIELD_TEXT_INPUT": "ARIA type name for a text input field, used by screen readers to identify the type of field.",
"ARIA_TYPE_FIELD_NUMBER": "ARIA type name for a number field, used by screen readers to identify the type of field.",
"ARIA_TYPE_FIELD_DROPDOWN": "ARIA type name for a dropdown field, used by screen readers to identify the type of field.",
"ARIA_TYPE_FIELD_IMAGE": "ARIA type name of an image field, used by screen readers to identify the type of field.",
"ARIA_TYPE_FIELD_CHECKBOX": "ARIA type name of an checkbox 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'",
"FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'"
"FIELD_LABEL_OPTION_INDEX": "Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1. \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1 \n\nExamples:\n* 'Option 1'\n* 'Option 2'",
"FIELD_LABEL_CHECKBOX_CHECKED": "Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.",
"FIELD_LABEL_CHECKBOX_UNCHECKED": "Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.",
"FIELD_LABEL_VARIABLE": "Label for a variable field option, used by screen readers to identify the options in a variable dropdown field. \n\nParameters:\n* %1 - the name of the variable represented by the option \n\nExamples:\n* 'Variable 'item''\n* 'Variable 'x''"
}
+19 -2
View File
@@ -139,7 +139,7 @@ Blockly.Msg.REDO = 'Redo';
Blockly.Msg.CHANGE_VALUE_TITLE = 'Change value:';
/** @type {string} */
/// dropdown choice - When the user clicks on a variable block, this is one of the dropdown menu choices. It is used to rename the current variable. See [https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu].
Blockly.Msg.RENAME_VARIABLE = 'Rename variable...';
Blockly.Msg.RENAME_VARIABLE = 'Rename the "%1" variable';
/** @type {string} */
/// prompt - Prompts the user to enter the new name for the selected variable. See [https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu https://github.com/RaspberryPiFoundation/blockly/wiki/Variables#dropdown-menu].\n\nParameters:\n* %1 - the name of the variable to be renamed.
Blockly.Msg.RENAME_VARIABLE_TITLE = 'Rename all "%1" variables to:';
@@ -1940,6 +1940,12 @@ Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number';
/// ARIA type name for a dropdown field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_DROPDOWN = 'dropdown';
/** @type {string} */
/// ARIA type name of an image field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_IMAGE = 'image';
/** @type {string} */
/// ARIA type name of an checkbox field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_CHECKBOX = 'checkbox';
/** @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"
@@ -1948,4 +1954,15 @@ Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1';
/// Label for an unlabeled dropdown field option, used by screen readers to identify options in a dropdown field. Placeholder corresponds to the index of the option in the dropdown, starting at 1.
/// \n\nParameters:\n* %1 - the index of the option in the dropdown, starting at 1
/// \n\nExamples:\n* "Option 1"\n* "Option 2"
Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1';
Blockly.Msg.FIELD_LABEL_OPTION_INDEX = 'Option %1';
/** @type {string} */
/// Label for a checked checkbox field, used by screen readers to identify the state of a checkbox field.
Blockly.Msg.FIELD_LABEL_CHECKBOX_CHECKED = 'Checked';
/** @type {string} */
/// Label for an unchecked checkbox field, used by screen readers to identify the state of a checkbox field.
Blockly.Msg.FIELD_LABEL_CHECKBOX_UNCHECKED = 'Not checked';
/** @type {string} */
/// Label for a variable field option, used by screen readers to identify the options in a variable dropdown field.
/// \n\nParameters:\n* %1 - the name of the variable represented by the option
/// \n\nExamples:\n* 'Variable "item"'\n* 'Variable "x"'
Blockly.Msg.FIELD_LABEL_VARIABLE = 'Variable "%1"';
@@ -293,4 +293,50 @@ suite('Checkbox Fields', function () {
this.assertValue(false);
});
});
suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
this.block = this.workspace.newBlock('test_fields_checkbox');
this.field = this.block.getField('CHECKBOX');
this.block.initSvg();
this.block.render();
this.focusableElement = this.field.getClickTarget_();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'checkbox');
});
test('Field ARIA label is type name', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.equal(fieldLabel, 'checkbox');
});
test('Field does not include value in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.isFalse(fieldLabel.toLowerCase().includes('checked'));
});
test('Hidden when in a flyout', function () {
this.block.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('Focusable element has role of checkbox', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'checkbox');
});
test('Focusable element has correct default ARIA checked state', function () {
const ariaChecked = this.focusableElement.getAttribute('aria-checked');
assert.equal(ariaChecked, 'true');
});
test('Focusable element updates ARIA checked state on setValue', function () {
this.field.setValue(false);
const ariaChecked = this.focusableElement.getAttribute('aria-checked');
assert.equal(ariaChecked, 'false');
});
});
});
@@ -345,6 +345,10 @@ suite('Dropdown Fields', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'dropdown:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'dropdown:');
});
test('Focusable element has role of button', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'button');
@@ -479,7 +483,7 @@ suite('Dropdown Fields', function () {
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'A');
assert.include(label, 'A');
});
test('Image ARIA label is prioritized over alt text', function () {
this.field.dropdownCreate();
@@ -487,7 +491,7 @@ suite('Dropdown Fields', function () {
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Letter B');
assert.include(label, 'Letter B');
});
});
suite('Dropdown with HTMLElement options', function () {
@@ -549,35 +553,35 @@ suite('Dropdown Fields', function () {
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Explicit A label');
assert.include(label, 'Explicit A label');
});
test('HTMLElement ariaLabel prioritized over other properties', function () {
this.field.setValue('B');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Element ARIA');
assert.include(label, 'Element ARIA');
});
test('HTMLElement title is used when ariaLabel is missing', function () {
this.field.setValue('C');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Title text');
assert.include(label, 'Title text');
});
test('HTMLElement innerText is used as final fallback', function () {
this.field.setValue('D');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Inner text');
assert.include(label, 'Inner text');
});
test('Empty label falls back to option index', function () {
this.field.setValue('E');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Option 5');
assert.include(label, 'Option 5');
});
});
});
@@ -348,4 +348,55 @@ suite('Image Fields', function () {
});
});
});
suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
});
suite('Image without click handler', function () {
setup(function () {
this.block = this.workspace.newBlock('text');
this.field = this.block.inputList[0].fieldRow[0];
this.block.initSvg();
this.block.render();
this.focusableElement = this.field.getFocusableElement();
});
test('Block has field type name in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'image:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'image:');
});
test('Block has image alt text in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, this.field.altText);
});
test('Focusable element has role of presentation', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'presentation');
});
test('Hidden when in a flyout', function () {
this.block.isInFlyout = true;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
assert.equal(ariaHidden, 'true');
});
});
suite('Image with click handler', function () {
test('Focusable element has role of button', function () {
const block = this.workspace.newBlock('test_images_clickhandler');
const field = block.getField('IMAGE');
block.initSvg();
block.render();
const focusableElement = field.getFocusableElement();
const role = focusableElement.getAttribute('role');
assert.equal(role, 'button');
});
});
});
});
@@ -223,4 +223,20 @@ suite('Label Fields', function () {
});
});
});
suite('ARIA', function () {
test('Is hidden', function () {
const workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
const block = workspace.newBlock('text_print');
const field = block.inputList[0].fieldRow[0];
block.initSvg();
block.render();
const focusableElement = field.getFocusableElement();
const ariaHidden = focusableElement.getAttribute('aria-hidden');
assert.equal(ariaHidden, 'true');
});
});
});
@@ -518,6 +518,10 @@ suite('Number Fields', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'number:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'number:');
});
test('Focusable element has role of button', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'button');
@@ -609,6 +609,10 @@ suite('Text Input Fields', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'text:');
});
test('Field has field type name in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'text:');
});
test('Focusable element has role of button', function () {
const role = this.focusableElement.getAttribute('role');
assert.equal(role, 'button');
@@ -204,9 +204,17 @@ suite('Variable Fields', function () {
for (let i = 0, option; (option = expectedVarOptions[i]); i++) {
assert.deepEqual(dropdownOptions[i], option);
}
assert.include(dropdownOptions[dropdownOptions.length - 2][0], 'Rename');
const varName = fieldVariable.getText();
assert.include(dropdownOptions[dropdownOptions.length - 1][0], 'Delete');
const renameLabel = dropdownOptions[dropdownOptions.length - 2][0];
const deleteLabel = dropdownOptions[dropdownOptions.length - 1][0];
// Expect the rename and delete options to include the variable name.
assert.include(renameLabel, 'Rename');
assert.include(renameLabel, varName);
assert.include(deleteLabel, 'Delete');
assert.include(deleteLabel, varName);
};
test('Contains variables created before field', function () {
this.workspace.getVariableMap().createVariable('name1', '', 'id1');
@@ -641,4 +649,75 @@ suite('Variable Fields', function () {
assert.equal(variable.getId(), 'id2');
});
});
suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
this.block = this.workspace.newBlock('variables_set');
this.field = this.block.getField('VAR');
this.block.initSvg();
this.block.render();
this.focusableElement = this.field.getFocusableElement();
});
test('Block has dropdown field type name and "Variable" qualifier in ARIA label', function () {
const blockLabel = this.block.getAriaLabel();
assert.include(blockLabel, 'dropdown:');
});
test('Field has dropdown field type name and "Variable" qualifier in ARIA label', function () {
const fieldLabel = this.focusableElement.getAttribute('aria-label');
assert.include(fieldLabel, 'dropdown:');
assert.include(fieldLabel, 'Variable');
});
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.block.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('Does not have aria-expanded when dropdown is closed', function () {
const ariaExpanded = this.focusableElement.getAttribute('aria-expanded');
assert.equal(ariaExpanded, 'false');
});
test('Has aria-expanded when dropdown is open', function () {
this.field.showEditor_();
const ariaExpanded = this.focusableElement.getAttribute('aria-expanded');
assert.equal(ariaExpanded, 'true');
this.workspace.hideChaff();
});
test('Has aria-haspopup of listbox', function () {
const ariaHasPopup = this.focusableElement.getAttribute('aria-haspopup');
assert.equal(ariaHasPopup, 'listbox');
});
test('Has aria-controls that matches the ID of the dropdown menu', function () {
this.field.showEditor_();
const ariaControls = this.focusableElement.getAttribute('aria-controls');
const menuId = this.field.menu_.id;
assert.equal(ariaControls, menuId);
this.workspace.hideChaff();
});
test('Has placeholder ARIA label by default', function () {
const label = this.focusableElement.getAttribute('aria-label');
assert.include(label, 'item');
});
test('New selected option updates ARIA label', function () {
const initialLabel = this.focusableElement.getAttribute('aria-label');
assert.include(initialLabel, 'item');
const newVariable = this.workspace
.getVariableMap()
.createVariable('newVar');
this.field.getOptions(false); // Invalidate cached options.
this.field.setValue(newVariable.getId());
const updatedLabel = this.focusableElement.getAttribute('aria-label');
assert.include(updatedLabel, 'newVar');
});
});
});