feat: FieldDropdown ARIA (#9766)

* feat: FieldDropdown aria

* fix: clean up tests

* fix: code review
This commit is contained in:
Michael Harvey
2026-04-27 16:32:04 -04:00
committed by GitHub
parent cffbe7c60e
commit e3672f1581
12 changed files with 538 additions and 31 deletions
+1 -1
View File
@@ -5,7 +5,6 @@
*/
import type {BlockSvg} from './block_svg.js';
import {RenderedConnection} from './blockly.js';
import {ConnectionType} from './connection_type.js';
import type {Input} from './inputs/input.js';
import {inputTypes} from './inputs/input_types.js';
@@ -14,6 +13,7 @@ import {
isSelectableToolboxItem,
} from './interfaces/i_selectable_toolbox_item.js';
import {Msg} from './msg.js';
import {RenderedConnection} from './rendered_connection.js';
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
/**
+178 -12
View File
@@ -25,6 +25,7 @@ import * as fieldRegistry from './field_registry.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import {Msg} from './msg.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
@@ -98,6 +99,12 @@ export class FieldDropdown extends Field<string> {
/** The total vertical padding above and below an image. */
protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2;
/**
* True once the fields DOM has been created and it is safe to run ARIA
* updates in response to value changes.
*/
isInitialized: boolean = false;
/**
* @param menuGenerator A non-empty array of options for a dropdown list, or a
* function which generates these options. Also accepts Field.SKIP_SETUP
@@ -197,6 +204,8 @@ export class FieldDropdown extends Field<string> {
dom.addClass(this.fieldGroup_, 'blocklyField');
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
}
this.recomputeAriaContext();
this.isInitialized = true;
}
/**
@@ -295,6 +304,7 @@ export class FieldDropdown extends Field<string> {
}
this.applyColour();
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, true);
}
/** Create the dropdown editor. */
@@ -306,6 +316,11 @@ export class FieldDropdown extends Field<string> {
const menu = new Menu();
menu.setRole(aria.Role.LISTBOX);
this.menu_ = menu;
aria.setState(
this.getFocusableElement(),
aria.State.CONTROLS,
this.menu_.getId(),
);
const options = this.getOptions(false);
this.selectedMenuItem = null;
@@ -317,6 +332,7 @@ export class FieldDropdown extends Field<string> {
}
const [label, value] = option;
const ariaLabel = this.computeOptionAriaLabel(option, i);
const content = (() => {
if (isImageProperties(label)) {
// Convert ImageProperties to an HTMLImageElement.
@@ -327,7 +343,7 @@ export class FieldDropdown extends Field<string> {
}
return label;
})();
const menuItem = new MenuItem(content, value);
const menuItem = new MenuItem(content, value, ariaLabel);
menuItem.setRole(aria.Role.OPTION);
menuItem.setRightToLeft(block.RTL);
menuItem.setCheckable(true);
@@ -350,6 +366,7 @@ export class FieldDropdown extends Field<string> {
this.menu_ = null;
this.selectedMenuItem = null;
this.applyColour();
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, false);
}
/**
@@ -472,6 +489,9 @@ export class FieldDropdown extends Field<string> {
this.selectedOption = option;
}
}
if (this.isInitialized) {
this.recomputeAriaContext();
}
}
/**
@@ -653,7 +673,7 @@ export class FieldDropdown extends Field<string> {
typeof HTMLElement !== 'undefined' &&
option instanceof HTMLElement
) {
return option.title ?? option.ariaLabel ?? option.innerText;
return option.title || (option.ariaLabel ?? option.innerText);
} else if (typeof option === 'string') {
return option;
}
@@ -705,9 +725,16 @@ export class FieldDropdown extends Field<string> {
return option;
}
const [label, value] = option;
const [label, value, ariaLabel] = option;
if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value];
const trimmedLabelOption: MenuOption = [
parsing.replaceMessageReferences(label),
value,
];
if (ariaLabel) {
trimmedLabelOption.push(ariaLabel);
}
return trimmedLabelOption;
}
hasNonTextContent = true;
@@ -716,14 +743,18 @@ export class FieldDropdown extends Field<string> {
const imageLabel = isImageProperties(label)
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
: label;
return [imageLabel, value];
const imageOptions: MenuOption = [imageLabel, value];
if (ariaLabel) {
imageOptions.push(ariaLabel);
}
return imageOptions;
});
if (hasNonTextContent || options.length < 2) {
return {options: trimmedOptions};
}
const stringOptions = trimmedOptions as [string, string][];
const stringOptions = trimmedOptions as [string, string, string][];
const stringLabels = stringOptions.map(([label]) => label);
const shortest = utilsString.shortestStringLength(stringLabels);
@@ -762,14 +793,20 @@ export class FieldDropdown extends Field<string> {
* @returns A new array with all of the option text trimmed.
*/
private applyTrim(
options: [string, string][],
options: [string, string, string?][],
prefixLength: number,
suffixLength: number,
): MenuOption[] {
return options.map(([text, value]) => [
text.substring(prefixLength, text.length - suffixLength),
value,
]);
return options.map(([text, value, ariaLabel]) => {
const trimmedText = text.substring(
prefixLength,
text.length - suffixLength,
);
return ariaLabel !== undefined
? [trimmedText, value, ariaLabel]
: [trimmedText, value];
});
}
/**
@@ -813,12 +850,140 @@ export class FieldDropdown extends Field<string> {
`Invalid option[${i}]: Each FieldDropdown option must have a string
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
);
} else if (option[2] && typeof option[2] !== 'string') {
foundError = true;
console.error(
`Invalid option[${i}]: Each FieldDropdown option ARIA label must be a string.
Found ${option[2]} in: ${option}`,
);
}
}
if (foundError) {
throw TypeError('Found invalid FieldDropdown 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_DROPDOWN'];
}
/**
* 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 | null {
// Note: This fallback is effectively unreachable since computeOptionAriaLabel
// always returns a non-empty string for non-separator options. It exists as a
// defensive safeguard.
return (
this.getSelectedAriaLabel() || this.getText() || Msg['FIELD_LABEL_EMPTY']
);
}
/**
* Returns the ARIA label for the currently selected dropdown option.
*
* @returns The computed ARIA label for the selected option, or `null` if no
* option is selected.
*/
private getSelectedAriaLabel(): string | null {
if (!this.selectedOption) {
return null;
}
const option = this.selectedOption;
const ariaLabel = this.computeOptionAriaLabel(
option,
this.getOptions(false).indexOf(option),
);
if (typeof ariaLabel === 'string') {
return ariaLabel;
}
return null;
}
/**
* Recomputes the ARIA role and label for this field.
*/
private recomputeAriaContext(): void {
const focusableElement = this.getFocusableElement();
if (!focusableElement) return;
if (this.getSourceBlock()?.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.
aria.setRole(focusableElement, aria.Role.BUTTON);
const label = this.computeAriaLabel(false);
aria.setState(focusableElement, aria.State.LABEL, label);
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_);
}
/**
* Computes an ARIA-friendly label for a dropdown option.
*
* The label is derived using a prioritized set of sources.
*
* Returned values are guaranteed to be non-empty strings for all non-separator
* options. Whitespace-only values are ignored when determining a usable label.
*
* @param option The dropdown option for which to compute the ARIA label.
* @param index The index of the option within the dropdown (0-based).
* @returns A string suitable for use as an ARIA label. Returns an empty string
* only if the option is a separator.
*/
private computeOptionAriaLabel(option: MenuOption, index: number): string {
if (option === FieldDropdown.SEPARATOR) return '';
const [label, , explicitAriaLabel] = option;
if (typeof explicitAriaLabel === 'string' && explicitAriaLabel.trim()) {
return explicitAriaLabel;
}
let text: string | null = null;
if (isImageProperties(label)) {
text = label.ariaLabel ?? label.alt;
} else if (
typeof HTMLElement !== 'undefined' &&
label instanceof HTMLElement
) {
// This chain is similar to getText_, but prioritizes ariaLabel over title.
text = label.ariaLabel ?? (label.title || label.innerText);
} else if (typeof label === 'string') {
text = label;
}
if (text && text.trim()) {
return text;
}
// If we can't find any text to use for the ARIA label, use the option index.
return Msg['FIELD_LABEL_OPTION_INDEX'].replace('%1', String(index + 1));
}
}
/**
@@ -850,6 +1015,7 @@ export interface ImageProperties {
alt: string;
width: number;
height: number;
ariaLabel?: string;
}
/**
@@ -860,7 +1026,7 @@ export interface ImageProperties {
* the language-neutral value.
*/
export type MenuOption =
| [string | ImageProperties | HTMLElement, string]
| [string | ImageProperties | HTMLElement, string, string?]
| 'separator';
/**
+14
View File
@@ -18,6 +18,7 @@ import {
FieldInputValidator,
} 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';
@@ -341,6 +342,19 @@ export class FieldNumber extends FieldInput<number> {
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_NUMBER'];
}
}
fieldRegistry.register('field_number', FieldNumber);
+14
View File
@@ -21,6 +21,7 @@ import {
FieldInputValidator,
} from './field_input.js';
import * as fieldRegistry from './field_registry.js';
import {Msg} from './msg.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
@@ -89,6 +90,19 @@ export class FieldTextInput extends FieldInput<string> {
// override the static fromJson method.
return new this(text, undefined, 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_TEXT_INPUT'];
}
}
fieldRegistry.register('field_input', FieldTextInput);
+9
View File
@@ -16,6 +16,7 @@ import type {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as idGenerator from './utils/idgenerator.js';
import type {Size} from './utils/size.js';
import * as style from './utils/style.js';
@@ -62,6 +63,9 @@ export class Menu {
/** ARIA name for this menu. */
private roleName: aria.Role | null = null;
/** The menu's ID. */
private id: string = idGenerator.getNextUniqueId();
/** Constructs a new Menu instance. */
constructor() {}
@@ -86,6 +90,7 @@ export class Menu {
const element = document.createElement('div');
element.className = 'blocklyMenu';
element.tabIndex = 0;
element.id = this.getId();
if (this.roleName) {
aria.setRole(element, this.roleName);
}
@@ -483,4 +488,8 @@ export class Menu {
private getMenuItems(): MenuItem[] {
return this.menuItems.filter((item) => item instanceof MenuItem);
}
getId(): string {
return this.id;
}
}
+20 -2
View File
@@ -43,6 +43,9 @@ export class MenuItem {
private actionHandler: ((obj: this, menuSelectEvent: Event) => void) | null =
null;
/** The unique ID for this menu item. */
private id: string = idGenerator.getNextUniqueId();
/**
* @param content Text caption to display as the content of the item, or a
* HTML element to display.
@@ -51,6 +54,7 @@ export class MenuItem {
constructor(
private readonly content: string | HTMLElement,
private readonly opt_value?: string,
private readonly ariaLabel?: string,
) {}
/**
@@ -60,7 +64,7 @@ export class MenuItem {
*/
createDom(): Element {
const element = document.createElement('div');
element.id = idGenerator.getNextUniqueId();
element.id = this.getId();
this.element = element;
// Set class and style
@@ -72,6 +76,10 @@ export class MenuItem {
(this.rightToLeft ? 'blocklyMenuItemRtl ' : '');
const content = document.createElement('div');
aria.setState(element, aria.State.LABEL, this.getAriaLabel());
// The presentation role is used to prevent screen readers from also reading the
// content or its descendants.
aria.setRole(content, aria.Role.PRESENTATION);
content.className = 'blocklyMenuItemContent';
let contentDom: Node = this.content as HTMLElement;
@@ -100,6 +108,15 @@ export class MenuItem {
return element;
}
/**
* Gets the ARIA label for this menu item.
*/
getAriaLabel(): string {
// This fallback should only be hit by Context Menu items as all
// FieldDropdown options should have an ARIA label.
return this.ariaLabel || String(this.content);
}
/** Dispose of this menu item. */
dispose() {
this.element = null;
@@ -122,7 +139,7 @@ export class MenuItem {
* @internal
*/
getId(): string {
return this.element!.id;
return this.id;
}
/**
@@ -276,6 +293,7 @@ export class MenuItem {
}
const checkbox = document.createElement('div');
aria.setState(checkbox, aria.State.HIDDEN, true);
checkbox.className = 'blocklyMenuItemCheckbox ';
this.getElement()
?.querySelector('.blocklyMenuItemContent')
+7 -3
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-04-21 17:30:08.288719",
"lastupdated": "2026-04-24 15:03:55.288228",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -484,6 +484,10 @@
"ANNOUNCE_MOVE_TO": "moving %1 %2 to %3 %4",
"ANNOUNCE_MOVE_CANCELED": "Canceled movement",
"FIELD_LABEL_EMPTY": "empty",
"ARIA_TYPE_FIELD_INPUT": "input field",
"FIELD_LABEL_EDIT_PREFIX": "Edit %1"
"ARIA_TYPE_FIELD_INPUT": "input",
"ARIA_TYPE_FIELD_TEXT_INPUT": "text",
"ARIA_TYPE_FIELD_NUMBER": "number",
"ARIA_TYPE_FIELD_DROPDOWN": "dropdown",
"FIELD_LABEL_EDIT_PREFIX": "Edit %1",
"FIELD_LABEL_OPTION_INDEX": "Option %1"
}
+5 -1
View File
@@ -492,5 +492,9 @@
"ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.",
"FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.",
"ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input 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'"
"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.",
"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'"
}
+16 -2
View File
@@ -1929,9 +1929,23 @@ Blockly.Msg.ANNOUNCE_MOVE_CANCELED = 'Canceled movement';
Blockly.Msg.FIELD_LABEL_EMPTY = 'empty';
/** @type {string} */
/// ARIA type name for an input field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field';
Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input';
/** @type {string} */
/// ARIA type name for a text input field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_TEXT_INPUT = 'text';
/** @type {string} */
/// ARIA type name for a number field, used by screen readers to identify the type of field.
Blockly.Msg.ARIA_TYPE_FIELD_NUMBER = 'number';
/** @type {string} */
/// 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} */
/// 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"
Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1';
Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1';
/** @type {string} */
/// 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';
@@ -325,4 +325,260 @@ suite('Dropdown Fields', function () {
this.assertValue('C', field);
});
});
suite('ARIA', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
});
suite('Simple Dropdown', function () {
setup(function () {
this.block = this.workspace.newBlock('logic_boolean');
this.field = this.block.getField('BOOL');
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, 'dropdown:');
});
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, 'true');
});
test('setValue updates ARIA label', function () {
const initialLabel = this.focusableElement.getAttribute('aria-label');
assert.include(initialLabel, 'true');
this.field.setValue('FALSE');
const updatedLabel = this.focusableElement.getAttribute('aria-label');
assert.include(updatedLabel, 'false');
});
});
suite('Dropdown with Option ARIA labels', function () {
setup(function () {
Blockly.defineBlocksWithJsonArray([
{
'type': 'math_op',
'message0': '%1',
'args0': [
{
'type': 'field_dropdown',
'name': 'OP',
'options': [
['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'],
['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'],
['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY', 'Times'],
['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE', 'Divided by'],
['%{BKY_MATH_POWER_SYMBOL}', 'POWER', 'To the power of'],
],
},
],
},
]);
const block = this.workspace.newBlock('math_op');
block.initSvg();
block.render();
this.field = block.getField('OP');
});
test('Option ARIA labels are included in field ARIA label', function () {
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.include(label, 'Plus');
});
test('Option ARIA labels are included in field ARIA label when value is changed', function () {
this.field.setValue('DIVIDE');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.include(label, 'Divided by');
});
});
suite('Dropdown with image options', function () {
setup(function () {
Blockly.defineBlocksWithJsonArray([
{
'type': 'image_dropdown_test',
'message0': '%1',
'args0': [
{
'type': 'field_dropdown',
'name': 'IMG',
'options': [
[
{
'src':
'https://blockly-demo.appspot.com/static/tests/media/a.png',
'width': 32,
'height': 32,
'alt': 'A',
},
'A',
],
[
{
'src':
'https://blockly-demo.appspot.com/static/tests/media/b.png',
'width': 32,
'height': 32,
'alt': 'B',
'ariaLabel': 'Letter B',
},
'B',
],
],
},
],
},
]);
const block = this.workspace.newBlock('image_dropdown_test');
block.initSvg();
block.render();
this.field = block.getField('IMG');
});
test('Image alt text is included in ARIA label', function () {
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'A');
});
test('Image ARIA label is prioritized over alt text', function () {
this.field.dropdownCreate();
this.field.setValue('B');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(label, 'Letter B');
});
});
suite('Dropdown with HTMLElement options', function () {
setup(function () {
function makeElementOption({ariaLabel, title, innerText}) {
const element = document.createElement('div');
if (ariaLabel) element.ariaLabel = ariaLabel;
if (title) element.title = title;
if (innerText) element.innerText = innerText;
return element;
}
const options = [
[
makeElementOption({
ariaLabel: 'Ignored',
title: 'Ignored',
innerText: 'Ignored',
}),
'A',
'Explicit A label',
],
[
makeElementOption({
ariaLabel: 'Element ARIA',
title: 'Ignored',
innerText: 'Ignored',
}),
'B',
],
[
makeElementOption({
title: 'Title text',
innerText: 'Ignored',
}),
'C',
],
[makeElementOption({innerText: 'Inner text'}), 'D'],
[makeElementOption({}), 'E'],
];
Blockly.Blocks['aria_dropdown_test'] = {
init: function () {
this.appendDummyInput().appendField(
new Blockly.FieldDropdown(options),
'OP',
);
this.setOutput(true, null);
this.setColour(230);
},
};
const block = this.workspace.newBlock('aria_dropdown_test');
block.initSvg();
block.render();
this.field = block.getField('OP');
});
test('Explicit ARIA label overrides all other label sources', function () {
this.field.setValue('A');
const label = this.field
.getFocusableElement()
.getAttribute('aria-label');
assert.equal(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');
});
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');
});
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');
});
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');
});
});
});
});
@@ -507,19 +507,23 @@ suite('Number Fields', function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
const block = this.workspace.newBlock('math_number');
this.field = block.getField('NUM');
block.initSvg();
block.render();
this.block = this.workspace.newBlock('math_number');
this.field = this.block.getField('NUM');
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, 'number:');
});
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.field.getSourceBlock().isInFlyout = true;
this.block.isInFlyout = true;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');
@@ -598,19 +598,23 @@ suite('Text Input Fields', function () {
this.workspace = Blockly.inject('blocklyDiv', {
renderer: 'geras',
});
const block = this.workspace.newBlock('text');
this.field = block.getField('TEXT');
block.initSvg();
block.render();
this.block = this.workspace.newBlock('text');
this.field = this.block.getField('TEXT');
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, 'text:');
});
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.field.getSourceBlock().isInFlyout = true;
this.block.isInFlyout = true;
// Force recompute of ARIA label.
this.field.setValue(this.field.getValue());
const ariaHidden = this.focusableElement.getAttribute('aria-hidden');