mirror of
https://github.com/google/blockly.git
synced 2026-04-29 08:30:11 +02:00
feat: FieldDropdown ARIA (#9766)
* feat: FieldDropdown aria * fix: clean up tests * fix: code review
This commit is contained in:
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 field’s 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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user