Files
blockly/core/field_image.ts
Ben Henning f2b332fe71 Merge pull request #9446 from BenHenning/fix-miscellaneous-screen-reader-issues
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #9301
Fixes #9312
Fixes #9313
Fixes part of #9304

### Proposed Changes

This introduces a variety of specific changes to resolve several issues for screen reader work, including introducing fundamental support for field labeling.

Specifically:
- Field labels have been simplified to only use their custom defined ARIA name otherwise they are null (and thus should be ignored for readout purposes) which wraps up the remaining high-level work for #9301 (#9450 tracks more specific follow-up work to improve upon what's been established at this point). The PR also introduces an ARIA override for number inputs in math blocks so that the readout is correct for them.
- Bubble labeling is more explicit now which is useful for mutators (#9312), warnings, and comments. The general improvement for bubbles wraps up the remaining work for #9313 as well since the core issue was resolved in #9351. By default a bubble has no ARIA label.
- #9304 is partly being addressed here with the change to field images: they are no longer being added to the accessibility node tree unless they are actually navigable (that is, clickable). Part of #9304's goal is to remove extraneous nodes.
- Finally, a typo was fixed for 'replaceable blocks' since these were not reading out correctly. This was noticed in passing and isn't directly related to the other issues.

### Reason for Changes

This PR is largely being used as a basis for one particularly significant issue: #9301. Field labeling has undergone several iterations over the past few months and the team seems comfortable sticking with a "do as little as possible" approach when determining the label, thus justifying the need for expecting more specific customization (i.e. #9450). To this end it's important to be clear that getting fields to a good state is not actually "done" but the need to track it as a large incomplete thing has ended. Note that one important part of #9301 was updating field plugins to be accessible--this largely seems unnecessary as-is as it will be completely dependent on the needs of future user tests. The long-term plan will need to account for making all fields in `blockly-samples` accessible (per #9307).

Some of the terminology used here (e.g. for bubbles) will likely need to change after user testing, but it's important to establish that _something_ correct is communicated even if the terminology may require scaffolding and/or refinement.

It's important to note that while non-clickable field images are no longer in the node graph, their ARIA presence still exists as part of the fluent block labeling solution. That is, `FieldImage`'s alt text is used as part of constructing a fluent block label (sometimes to confusing effect--see #9452).

### Test Coverage

No tests needed since these are experimental changes and do not change existing test behaviors.

### Documentation

No documentation changes are needed for these experimental changes.

### Additional Information

None.
2025-11-12 18:09:30 -08:00

319 lines
8.6 KiB
TypeScript

/**
* @license
* Copyright 2012 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Image field. Used for pictures, icons, etc.
*
* @class
*/
// Former goog.module ID: Blockly.FieldImage
import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
import {Svg} from './utils/svg.js';
/**
* Class for an image on a block.
*/
export class FieldImage extends Field<string> {
/**
* Vertical padding below the image, which is included in the reported height
* of the field.
*/
private static readonly Y_PADDING = 1;
protected readonly imageHeight: number;
/** The function to be called when this field is clicked. */
private clickHandler: ((p1: FieldImage) => void) | null = null;
/** The rendered field's image element. */
protected imageElement: SVGImageElement | null = null;
/**
* Editable fields usually show some sort of UI indicating they are
* editable. This field should not.
*/
override readonly EDITABLE = false;
/**
* Used to tell if the field needs to be rendered the next time the block is
* rendered. Image fields are statically sized, and only need to be
* rendered at initialization.
*/
protected override isDirty_ = false;
/** Whether to flip this image in RTL. */
private flipRtl = false;
/** Alt text of this image. */
private altText = '';
/**
* @param src The URL of the image.
* Also accepts Field.SKIP_SETUP if you wish to skip setup (only used by
* subclasses that want to handle configuration and setting the field value
* after their own constructors have run).
* @param width Width of the image.
* @param height Height of the image.
* @param alt Optional alt text for when block is collapsed.
* @param onClick Optional function to be called when the image is
* clicked. If onClick is defined, alt must also be defined.
* @param flipRtl Whether to flip the icon in RTL.
* @param config A map of options used to configure the field.
* See the [field creation documentation]{@link
* https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/image#creation}
* for a list of properties this parameter supports.
*/
constructor(
src: string | typeof Field.SKIP_SETUP,
width: string | number,
height: string | number,
alt?: string,
onClick?: (p1: FieldImage) => void,
flipRtl?: boolean,
config?: FieldImageConfig,
) {
super(Field.SKIP_SETUP);
const imageHeight = Number(parsing.replaceMessageReferences(height));
const imageWidth = Number(parsing.replaceMessageReferences(width));
if (isNaN(imageHeight) || isNaN(imageWidth)) {
throw Error(
'Height and width values of an image field must cast to' + ' numbers.',
);
}
if (imageHeight <= 0 || imageWidth <= 0) {
throw Error(
'Height and width values of an image field must be greater' +
' than 0.',
);
}
/** The size of the area rendered by the field. */
this.size_ = new Size(imageWidth, imageHeight + FieldImage.Y_PADDING);
/**
* Store the image height, since it is different from the field height.
*/
this.imageHeight = imageHeight;
if (typeof onClick === 'function') {
this.clickHandler = onClick;
}
if (src === Field.SKIP_SETUP) return;
if (config) {
this.configure_(config);
} else {
this.flipRtl = !!flipRtl;
this.altText = parsing.replaceMessageReferences(alt) || '';
}
this.setValue(parsing.replaceMessageReferences(src));
}
/**
* Configure the field based on the given map of options.
*
* @param config A map of options to configure the field based on.
*/
protected override configure_(config: FieldImageConfig) {
super.configure_(config);
if (config.flipRtl) this.flipRtl = config.flipRtl;
if (config.alt) {
this.altText = parsing.replaceMessageReferences(config.alt);
}
}
/**
* Create the block UI for this image.
*/
override initView() {
this.imageElement = dom.createSvgElement(
Svg.IMAGE,
{
'height': this.imageHeight + 'px',
'width': this.size_.width + 'px',
'alt': this.altText,
},
this.fieldGroup_,
);
this.imageElement.setAttributeNS(
dom.XLINK_NS,
'xlink:href',
this.value_ as string,
);
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyImageField');
}
const element = this.getFocusableElement();
if (this.isClickable()) {
this.imageElement.style.cursor = 'pointer';
aria.setRole(element, aria.Role.BUTTON);
const label = [this.altText, this.getAriaName()]
.filter((item) => !!item)
.join(', ');
aria.setState(element, aria.State.LABEL, label);
} else {
// The field isn't navigable unless it's clickable.
aria.setRole(element, aria.Role.PRESENTATION);
}
}
override updateSize_() {}
// NOP
/**
* Ensure that the input value (the source URL) is a string.
*
* @param newValue The input value.
* @returns A string, or null if invalid.
*/
protected override doClassValidation_(newValue?: any): string | null {
if (typeof newValue !== 'string') {
return null;
}
return newValue;
}
/**
* Update the value of this image field, and update the displayed image.
*
* @param newValue The value to be saved. The default validator guarantees
* that this is a string.
*/
protected override doValueUpdate_(newValue: string) {
this.value_ = newValue;
if (this.imageElement) {
this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_);
}
}
/**
* Get whether to flip this image in RTL
*
* @returns True if we should flip in RTL.
*/
override getFlipRtl(): boolean {
return this.flipRtl;
}
/**
* Set the alt text of this image.
*
* @param alt New alt text.
*/
setAlt(alt: string | null) {
if (alt === this.altText) {
return;
}
this.altText = alt || '';
if (this.imageElement) {
this.imageElement.setAttribute('alt', this.altText);
}
}
/**
* Check whether this field should be clickable.
*
* @returns Whether this field is clickable.
*/
isClickable(): boolean {
// Images are only clickable if they have a click handler and fulfill the
// contract to be clickable: enabled and attached to an editable block.
return super.isClickable() && !!this.clickHandler;
}
/**
* If field click is called, and click handler defined,
* call the handler.
*/
protected override showEditor_() {
if (this.clickHandler) {
this.clickHandler(this);
}
}
/**
* Set the function that is called when this image is clicked.
*
* @param func The function that is called when the image is clicked, or null
* to remove.
*/
setOnClickHandler(func: ((p1: FieldImage) => void) | null) {
this.clickHandler = func;
}
/**
* Use the `getText_` developer hook to override the field's text
* representation.
* Return the image alt text instead.
*
* @returns The image alt text.
*/
protected override getText_(): string | null {
return this.altText;
}
/**
* Construct a FieldImage from a JSON arg object,
* dereferencing any string table references.
*
* @param options A JSON object with options (src, width, height, alt, and
* flipRtl).
* @returns The new field instance.
* @nocollapse
* @internal
*/
static override fromJson(options: FieldImageFromJsonConfig): FieldImage {
if (!options.src || !options.width || !options.height) {
throw new Error(
'src, width, and height values for an image field are' +
'required. The width and height must be non-zero.',
);
}
// `this` might be a subclass of FieldImage if that class doesn't override
// the static fromJson method.
return new this(
options.src,
options.width,
options.height,
undefined,
undefined,
undefined,
options,
);
}
}
fieldRegistry.register('field_image', FieldImage);
FieldImage.prototype.DEFAULT_VALUE = '';
/**
* Config options for the image field.
*/
export interface FieldImageConfig extends FieldConfig {
flipRtl?: boolean;
alt?: string;
}
/**
* fromJson config options for the image field.
*/
export interface FieldImageFromJsonConfig extends FieldImageConfig {
src?: string;
width?: number;
height?: number;
}