mirror of
https://github.com/google/blockly.git
synced 2026-01-04 23:50:12 +01:00
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8206 Fixes #8210 Fixes #8213 Fixes #8255 Fixes #8211 Fixes #8212 Fixes #8254 Fixes part of #9301 Fixes part of #9304 ### Proposed Changes This PR completes the remaining ARIA roles and properties needed for all core fields. Specifically: - #8206: A better name needed to be used for the checkbox value, plus there was an ARIA property missing for actually representing the checkbox state. The latter needed to be updated upon toggling the checkbox, as well. These changes bring checkbox fields in compliance with the ARIA checkbox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/. - #8210: This one required a lot of changes in order to adapt to the ARIA combobox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/. Specifically: - Menus needed to have a unique ID that's also exposed in order to link the combobox element to its menu when open. - ARIA's `activedescendant` proved very useful in ensuring that the current dropdown selection is correctly read when the combobox has focus but its menu isn't opened. - The default properties available for options (label and value) aren't very good for readout, so a custom ARIA property was added for much clearer option readouts. This is only demonstrated for the math arithmetic block for now. - The text element is normally hidden for ARIA but it's useful in conjunction with `activedescendant` to represent the current value selection. - Images have been handled here as well (partly as part of #8255) by leveraging their alt text for readouts. This actually seems to work quite well both for current value and selection. - #8213: Much of the improvements here come from the combobox (`FieldDropdown`) improvements explained above. However one additional bit was done to provide an explicit 'Variable <name>' readout for the purpose of clarity. This demonstrates some contextualization of the value of the field which may be a generally useful pattern to copy in other field contexts. - #8255: Image fields have been refined since they were redundantly specifying 'image' when an `image` ARIA role is already being used. Now only the alt text is supplied along with the role context. Note that images need special handling since they can sometimes be navigable (such as when they have click handlers). - #8211: Text input fields have had their labeling improved like all other fields, and the field's value is now exposed via its `text` element since this will show up as a `StaticText` node in the accessibility tree and automatically be read as part of the field's value. - #8212: This gets the same benefits as the previous point since those improvements were included for both text and number input. However, existing `valuemin` and `valuemax` ARIA properties have been removed. It seems these are really only useful when introducing a slider mechanism (see https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and from testing seems to not really be utilized for the basic text input that `FieldNumber` currently uses. It may be the case that this is a better pattern to use in the future, but it's more likely that other custom fields could benefit from more specific patterns like slider rather than `FieldNumber` being changed in that way. - #8254 and part of #9304: Field labels have been completely removed from the accessibility node tree since they can never be navigated to (as #8254 explains all labels will be included as part of the block's ARIA label itself for readout parity with navigation options). Note that it doesn't cover external fields (such as those supplied in blockly-samples), nor does it fully set up the infrastructure work for those. Ultimately that work needs to happen as part of #9301. Beyond the role work above, this PR also introduces some fundamental work for #9301. Specifically: - It demonstrates how block definitions could be used to introduce accessibility label customizations (in this case for the options of the arithmetic operator block's drop-down field, plus the block itself). - It sets up some central label computation for all fields, though more thought is needed on whether this is sufficient for custom fields outside of core Blockly and on how to properly contextualize labels for field values. Core Blockly's fields are fairly simple for representing values which is why that aspect of #9301 didn't need to be solved in this PR. Note that the field labeling here is being used to improve all of the fields above, but also it tries to aggressively fall back to the _next best_ label to be used (though it's possible to run out of options which is why fields still need contextually-specific fallbacks). ### Reason for Changes Generally the initial approach for implementing labels is leveraging as specific ARIA roles as exist to directly represent the element. This PR is completing that work for all of core Blockly's built-in fields, and laying some of the groundwork for generalizing this support for custom fields. Having specific roles does potentially introduce inconsistencies across screen readers (though should improve consistency across sites for a single screen reader), and expectations for behaviors (like shortcuts) that may need to be ignored or only partially supported (#9313 is discussing this). ### Test Coverage Only manual testing has been completed since this is experimental work. Video demonstrating most of the changes: [Screen recording 2025-10-01 4.05.35 PM.webm](https://github.com/user-attachments/assets/c7961caa-eae0-4585-8fd9-87d7cbe65988) ### Documentation N/A -- Experimental work. ### Additional Information This has only been tested on ChromeVox.
980 lines
31 KiB
TypeScript
980 lines
31 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2012 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Dropdown input field. Used for editable titles and variables.
|
|
* In the interests of a consistent UI, the toolbox shares some functions and
|
|
* properties with the context menu.
|
|
*
|
|
* @class
|
|
*/
|
|
// Former goog.module ID: Blockly.FieldDropdown
|
|
|
|
import type {BlockSvg} from './block_svg.js';
|
|
import * as dropDownDiv from './dropdowndiv.js';
|
|
import {
|
|
Field,
|
|
FieldConfig,
|
|
FieldValidator,
|
|
UnattachedFieldError,
|
|
} from './field.js';
|
|
import * as fieldRegistry from './field_registry.js';
|
|
import {Menu} from './menu.js';
|
|
import {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 dom from './utils/dom.js';
|
|
import * as idGenerator from './utils/idgenerator.js';
|
|
import * as parsing from './utils/parsing.js';
|
|
import {Size} from './utils/size.js';
|
|
import * as utilsString from './utils/string.js';
|
|
import {Svg} from './utils/svg.js';
|
|
|
|
/**
|
|
* Class for an editable dropdown field.
|
|
*/
|
|
export class FieldDropdown extends Field<string> {
|
|
/**
|
|
* Magic constant used to represent a separator in a list of dropdown items.
|
|
*/
|
|
static readonly SEPARATOR = 'separator';
|
|
|
|
static ARROW_CHAR = '▾';
|
|
|
|
/** A reference to the currently selected menu item. */
|
|
private selectedMenuItem: MenuItem | null = null;
|
|
|
|
/** The dropdown menu. */
|
|
protected menu_: Menu | null = null;
|
|
|
|
/**
|
|
* SVG image element if currently selected option is an image, or null.
|
|
*/
|
|
private imageElement: SVGImageElement | null = null;
|
|
|
|
/** Tspan based arrow element. */
|
|
private arrow: SVGTSpanElement | null = null;
|
|
|
|
/** SVG based arrow element. */
|
|
private svgArrow: SVGElement | null = null;
|
|
|
|
/**
|
|
* Serializable fields are saved by the serializer, non-serializable fields
|
|
* are not. Editable fields should also be serializable.
|
|
*/
|
|
override SERIALIZABLE = true;
|
|
|
|
protected menuGenerator_?: MenuGenerator;
|
|
|
|
/** A cache of the most recently generated options. */
|
|
private generatedOptions: MenuOption[] | null = null;
|
|
|
|
/**
|
|
* The prefix field label, of common words set after options are trimmed.
|
|
*
|
|
* @internal
|
|
*/
|
|
override prefixField: string | null = null;
|
|
|
|
/**
|
|
* The suffix field label, of common words set after options are trimmed.
|
|
*
|
|
* @internal
|
|
*/
|
|
override suffixField: string | null = null;
|
|
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
|
private selectedOption!: MenuOption;
|
|
override clickTarget_: SVGElement | null = null;
|
|
|
|
/**
|
|
* The y offset from the top of the field to the top of the image, if an image
|
|
* is selected.
|
|
*/
|
|
protected static IMAGE_Y_OFFSET = 5;
|
|
|
|
/** The total vertical padding above and below an image. */
|
|
protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2;
|
|
|
|
/**
|
|
* @param menuGenerator A non-empty array of options for a dropdown list, or a
|
|
* function which generates these options. 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 validator A function that is called to validate changes to the
|
|
* field's value. Takes in a language-neutral dropdown option & returns a
|
|
* validated language-neutral dropdown option, or null to abort the
|
|
* change.
|
|
* @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/dropdown#creation}
|
|
* for a list of properties this parameter supports.
|
|
* @throws {TypeError} If `menuGenerator` options are incorrectly structured.
|
|
*/
|
|
constructor(
|
|
menuGenerator: MenuGenerator,
|
|
validator?: FieldDropdownValidator,
|
|
config?: FieldDropdownConfig,
|
|
);
|
|
constructor(menuGenerator: typeof Field.SKIP_SETUP);
|
|
constructor(
|
|
menuGenerator: MenuGenerator | typeof Field.SKIP_SETUP,
|
|
validator?: FieldDropdownValidator,
|
|
config?: FieldDropdownConfig,
|
|
) {
|
|
super(Field.SKIP_SETUP);
|
|
|
|
// If we pass SKIP_SETUP, don't do *anything* with the menu generator.
|
|
if (menuGenerator === Field.SKIP_SETUP) return;
|
|
|
|
this.setOptions(menuGenerator);
|
|
|
|
if (config) {
|
|
this.configure_(config);
|
|
}
|
|
if (validator) {
|
|
this.setValidator(validator);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the field's value based on the given XML element. Should only be
|
|
* called by Blockly.Xml.
|
|
*
|
|
* @param fieldElement The element containing info about the field's state.
|
|
* @internal
|
|
*/
|
|
override fromXml(fieldElement: Element) {
|
|
if (this.isOptionListDynamic()) {
|
|
this.getOptions(false);
|
|
}
|
|
this.setValue(fieldElement.textContent);
|
|
}
|
|
|
|
/**
|
|
* Sets the field's value based on the given state.
|
|
*
|
|
* @param state The state to apply to the dropdown field.
|
|
* @internal
|
|
*/
|
|
override loadState(state: AnyDuringMigration) {
|
|
if (this.loadLegacyState(FieldDropdown, state)) {
|
|
return;
|
|
}
|
|
if (this.isOptionListDynamic()) {
|
|
this.getOptions(false);
|
|
}
|
|
this.setValue(state);
|
|
}
|
|
|
|
/**
|
|
* Create the block UI for this dropdown.
|
|
*/
|
|
override initView() {
|
|
if (this.shouldAddBorderRect_()) {
|
|
this.createBorderRect_();
|
|
} else {
|
|
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
|
|
}
|
|
this.createTextElement_();
|
|
|
|
this.imageElement = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_);
|
|
|
|
if (this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW) {
|
|
this.createSVGArrow_();
|
|
} else {
|
|
this.createTextArrow_();
|
|
}
|
|
|
|
if (this.borderRect_) {
|
|
dom.addClass(this.borderRect_, 'blocklyDropdownRect');
|
|
}
|
|
|
|
if (this.fieldGroup_) {
|
|
dom.addClass(this.fieldGroup_, 'blocklyField');
|
|
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
|
|
}
|
|
|
|
this.recomputeAria();
|
|
}
|
|
|
|
private recomputeAria() {
|
|
if (!this.fieldGroup_) return; // There's no element to set currently.
|
|
const element = this.getFocusableElement();
|
|
aria.setRole(element, aria.Role.COMBOBOX);
|
|
aria.setState(element, aria.State.HASPOPUP, aria.Role.LISTBOX);
|
|
aria.setState(element, aria.State.EXPANDED, !!this.menu_);
|
|
if (this.menu_) {
|
|
aria.setState(element, aria.State.CONTROLS, this.menu_.id);
|
|
} else {
|
|
aria.clearState(element, aria.State.CONTROLS);
|
|
}
|
|
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Dropdown');
|
|
|
|
// Ensure the selected item has its correct label presented since it may be
|
|
// different than the actual text presented to the user.
|
|
aria.setState(
|
|
this.getTextElement(),
|
|
aria.State.LABEL,
|
|
this.computeLabelForOption(this.selectedOption),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether or not the dropdown should add a border rect.
|
|
*
|
|
* @returns True if the dropdown field should add a border rect.
|
|
*/
|
|
protected shouldAddBorderRect_(): boolean {
|
|
return (
|
|
!this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||
|
|
(this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW &&
|
|
!this.getSourceBlock()?.isShadow())
|
|
);
|
|
}
|
|
|
|
/** Create a tspan based arrow. */
|
|
protected createTextArrow_() {
|
|
this.arrow = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_);
|
|
this.arrow!.appendChild(
|
|
document.createTextNode(
|
|
this.getSourceBlock()?.RTL
|
|
? FieldDropdown.ARROW_CHAR + ' '
|
|
: ' ' + FieldDropdown.ARROW_CHAR,
|
|
),
|
|
);
|
|
if (this.getConstants()!.FIELD_TEXT_BASELINE_CENTER) {
|
|
this.arrow.setAttribute('dominant-baseline', 'central');
|
|
}
|
|
if (this.getSourceBlock()?.RTL) {
|
|
this.getTextElement().insertBefore(this.arrow, this.textContent_);
|
|
} else {
|
|
this.getTextElement().appendChild(this.arrow);
|
|
}
|
|
}
|
|
|
|
/** Create an SVG based arrow. */
|
|
protected createSVGArrow_() {
|
|
this.svgArrow = dom.createSvgElement(
|
|
Svg.IMAGE,
|
|
{
|
|
'height': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
|
|
'width': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',
|
|
},
|
|
this.fieldGroup_,
|
|
);
|
|
this.svgArrow!.setAttributeNS(
|
|
dom.XLINK_NS,
|
|
'xlink:href',
|
|
this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_DATAURI,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a dropdown menu under the text.
|
|
*
|
|
* @param e Optional mouse event that triggered the field to open, or
|
|
* undefined if triggered programmatically.
|
|
*/
|
|
protected override showEditor_(e?: MouseEvent) {
|
|
const block = this.getSourceBlock();
|
|
if (!block) {
|
|
throw new UnattachedFieldError();
|
|
}
|
|
this.dropdownCreate();
|
|
if (!this.menu_) return;
|
|
|
|
if (e && typeof e.clientX === 'number') {
|
|
this.menu_.openingCoords = new Coordinate(e.clientX, e.clientY);
|
|
} else {
|
|
this.menu_.openingCoords = null;
|
|
}
|
|
|
|
// Remove any pre-existing elements in the dropdown.
|
|
dropDownDiv.clearContent();
|
|
// Element gets created in render.
|
|
const menuElement = this.menu_.render(dropDownDiv.getContentDiv());
|
|
dom.addClass(menuElement, 'blocklyDropdownMenu');
|
|
|
|
if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) {
|
|
const primaryColour = block.getColour();
|
|
const borderColour = (this.sourceBlock_ as BlockSvg).getColourTertiary();
|
|
dropDownDiv.setColour(primaryColour, borderColour);
|
|
}
|
|
|
|
dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));
|
|
|
|
dropDownDiv.getContentDiv().style.height = `${this.menu_.getSize().height}px`;
|
|
|
|
// Focusing needs to be handled after the menu is rendered and positioned.
|
|
// Otherwise it will cause a page scroll to get the misplaced menu in
|
|
// view. See issue #1329.
|
|
this.menu_.focus();
|
|
|
|
if (this.selectedMenuItem) {
|
|
this.menu_.setHighlighted(this.selectedMenuItem);
|
|
}
|
|
|
|
this.applyColour();
|
|
}
|
|
|
|
/** Create the dropdown editor. */
|
|
private dropdownCreate() {
|
|
const block = this.getSourceBlock();
|
|
if (!block) {
|
|
throw new UnattachedFieldError();
|
|
}
|
|
const menu = new Menu();
|
|
menu.setRole(aria.Role.LISTBOX);
|
|
this.menu_ = menu;
|
|
|
|
const options = this.getOptions(false);
|
|
this.selectedMenuItem = null;
|
|
for (let i = 0; i < options.length; i++) {
|
|
const option = options[i];
|
|
if (option === FieldDropdown.SEPARATOR) {
|
|
menu.addChild(new MenuSeparator());
|
|
continue;
|
|
}
|
|
|
|
const [label, value] = option;
|
|
const content = (() => {
|
|
if (isImageProperties(label)) {
|
|
// Convert ImageProperties to an HTMLImageElement.
|
|
const image = new Image(label.width, label.height);
|
|
image.src = label.src;
|
|
image.alt = label.alt;
|
|
return image;
|
|
}
|
|
return label;
|
|
})();
|
|
const menuItem = new MenuItem(
|
|
content,
|
|
value,
|
|
this.computeLabelForOption(option),
|
|
);
|
|
menuItem.setRole(aria.Role.OPTION);
|
|
menuItem.setRightToLeft(block.RTL);
|
|
menuItem.setCheckable(true);
|
|
menu.addChild(menuItem);
|
|
menuItem.setChecked(value === this.value_);
|
|
if (value === this.value_) {
|
|
this.selectedMenuItem = menuItem;
|
|
}
|
|
menuItem.onAction(this.handleMenuActionEvent, this);
|
|
}
|
|
|
|
this.recomputeAria();
|
|
}
|
|
|
|
private computeLabelForOption(option: MenuOption): string {
|
|
if (option === FieldDropdown.SEPARATOR) {
|
|
return ''; // Separators don't need labels.
|
|
} else if (!Array.isArray(option)) {
|
|
return ''; // Certain dynamic options aren't iterable. TODO: Figure this out. It breaks when opening certain test toolbox categories in the advanced playground.
|
|
}
|
|
const [label, value, optionalAriaLabel] = option;
|
|
const altText = isImageProperties(label) ? label.alt : null;
|
|
return (
|
|
altText ??
|
|
optionalAriaLabel ??
|
|
this.computeHumanReadableText(option) ??
|
|
String(value)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Disposes of events and DOM-references belonging to the dropdown editor.
|
|
*/
|
|
protected dropdownDispose_() {
|
|
if (this.menu_) {
|
|
this.menu_.dispose();
|
|
}
|
|
this.menu_ = null;
|
|
this.selectedMenuItem = null;
|
|
this.applyColour();
|
|
this.recomputeAria();
|
|
}
|
|
|
|
/**
|
|
* Handle an action in the dropdown menu.
|
|
*
|
|
* @param menuItem The MenuItem selected within menu.
|
|
*/
|
|
private handleMenuActionEvent(menuItem: MenuItem) {
|
|
dropDownDiv.hideIfOwner(this, true);
|
|
this.onItemSelected_(this.menu_ as Menu, menuItem);
|
|
}
|
|
|
|
/**
|
|
* Handle the selection of an item in the dropdown menu.
|
|
*
|
|
* @param menu The Menu component clicked.
|
|
* @param menuItem The MenuItem selected within menu.
|
|
*/
|
|
protected onItemSelected_(menu: Menu, menuItem: MenuItem) {
|
|
this.setValue(menuItem.getValue());
|
|
}
|
|
|
|
override setValue(newValue: AnyDuringMigration, fireChangeEvent = true) {
|
|
super.setValue(newValue, fireChangeEvent);
|
|
this.recomputeAria();
|
|
}
|
|
|
|
/**
|
|
* @returns True if the option list is generated by a function.
|
|
* Otherwise false.
|
|
*/
|
|
isOptionListDynamic(): boolean {
|
|
return typeof this.menuGenerator_ === 'function';
|
|
}
|
|
|
|
/**
|
|
* Return a list of the options for this dropdown.
|
|
*
|
|
* @param useCache For dynamic options, whether or not to use the cached
|
|
* options or to re-generate them.
|
|
* @returns A non-empty array of option tuples:
|
|
* (human-readable text or image, language-neutral name).
|
|
* @throws {TypeError} If generated options are incorrectly structured.
|
|
*/
|
|
getOptions(useCache?: boolean): MenuOption[] {
|
|
if (!this.menuGenerator_) {
|
|
// A subclass improperly skipped setup without defining the menu
|
|
// generator.
|
|
throw TypeError('A menu generator was never defined.');
|
|
}
|
|
if (Array.isArray(this.menuGenerator_)) return this.menuGenerator_;
|
|
if (useCache && this.generatedOptions) return this.generatedOptions;
|
|
|
|
this.generatedOptions = this.menuGenerator_();
|
|
this.validateOptions(this.generatedOptions);
|
|
return this.generatedOptions;
|
|
}
|
|
|
|
/**
|
|
* Update the options on this dropdown. This will reset the selected item to
|
|
* the first item in the list.
|
|
*
|
|
* @param menuGenerator The array of options or a generator function.
|
|
*/
|
|
setOptions(menuGenerator: MenuGenerator) {
|
|
if (Array.isArray(menuGenerator)) {
|
|
this.validateOptions(menuGenerator);
|
|
const trimmed = this.trimOptions(menuGenerator);
|
|
this.menuGenerator_ = trimmed.options;
|
|
this.prefixField = trimmed.prefix || null;
|
|
this.suffixField = trimmed.suffix || null;
|
|
} else {
|
|
this.menuGenerator_ = menuGenerator;
|
|
}
|
|
// The currently selected option. The field is initialized with the
|
|
// first option selected.
|
|
this.selectedOption = this.getOptions(false)[0];
|
|
this.setValue(this.selectedOption[1]);
|
|
}
|
|
|
|
/**
|
|
* Ensure that the input value is a valid language-neutral option.
|
|
*
|
|
* @param newValue The input value.
|
|
* @returns A valid language-neutral option, or null if invalid.
|
|
*/
|
|
protected override doClassValidation_(
|
|
newValue: string,
|
|
): string | null | undefined;
|
|
protected override doClassValidation_(newValue?: string): string | null;
|
|
protected override doClassValidation_(
|
|
newValue?: string,
|
|
): string | null | undefined {
|
|
const options = this.getOptions(true);
|
|
const isValueValid = options.some((option) => option[1] === newValue);
|
|
|
|
if (!isValueValid) {
|
|
if (this.sourceBlock_) {
|
|
console.warn(
|
|
"Cannot set the dropdown's value to an unavailable option." +
|
|
' Block type: ' +
|
|
this.sourceBlock_.type +
|
|
', Field name: ' +
|
|
this.name +
|
|
', Value: ' +
|
|
newValue,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
return newValue;
|
|
}
|
|
|
|
/**
|
|
* Update the value of this dropdown field.
|
|
*
|
|
* @param newValue The value to be saved. The default validator guarantees
|
|
* that this is one of the valid dropdown options.
|
|
*/
|
|
protected override doValueUpdate_(newValue: string) {
|
|
super.doValueUpdate_(newValue);
|
|
const options = this.getOptions(true);
|
|
for (let i = 0, option; (option = options[i]); i++) {
|
|
if (option[1] === this.value_) {
|
|
this.selectedOption = option;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the dropdown arrow to match the colour/style of the block.
|
|
*/
|
|
override applyColour() {
|
|
const sourceBlock = this.sourceBlock_ as BlockSvg;
|
|
if (this.borderRect_) {
|
|
this.borderRect_.setAttribute('stroke', sourceBlock.getColourTertiary());
|
|
if (this.menu_) {
|
|
this.borderRect_.setAttribute('fill', sourceBlock.getColourTertiary());
|
|
} else {
|
|
this.borderRect_.setAttribute('fill', 'transparent');
|
|
}
|
|
}
|
|
// Update arrow's colour.
|
|
if (sourceBlock && this.arrow) {
|
|
if (sourceBlock.isShadow()) {
|
|
this.arrow.style.fill = sourceBlock.getColourSecondary();
|
|
} else {
|
|
this.arrow.style.fill = sourceBlock.getColour();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Draws the border with the correct width. */
|
|
protected override render_() {
|
|
// Hide both elements.
|
|
this.getTextContent().nodeValue = '';
|
|
this.imageElement!.style.display = 'none';
|
|
|
|
// Show correct element.
|
|
const option = this.selectedOption && this.selectedOption[0];
|
|
if (isImageProperties(option)) {
|
|
this.renderSelectedImage(option);
|
|
} else {
|
|
this.renderSelectedText();
|
|
}
|
|
|
|
this.positionBorderRect_();
|
|
}
|
|
|
|
/**
|
|
* Renders the selected option, which must be an image.
|
|
*
|
|
* @param imageJson Selected option that must be an image.
|
|
*/
|
|
private renderSelectedImage(imageJson: ImageProperties) {
|
|
const block = this.getSourceBlock();
|
|
if (!block) {
|
|
throw new UnattachedFieldError();
|
|
}
|
|
const imageElement = this.imageElement!;
|
|
imageElement.style.display = '';
|
|
imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', imageJson.src);
|
|
imageElement.setAttribute('height', String(imageJson.height));
|
|
imageElement.setAttribute('width', String(imageJson.width));
|
|
|
|
const imageHeight = Number(imageJson.height);
|
|
const imageWidth = Number(imageJson.width);
|
|
|
|
// Height and width include the border rect.
|
|
const hasBorder = !!this.borderRect_;
|
|
const height = Math.max(
|
|
hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
|
|
imageHeight + FieldDropdown.IMAGE_Y_PADDING,
|
|
);
|
|
const xPadding = hasBorder
|
|
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
|
|
: 0;
|
|
let arrowWidth = 0;
|
|
if (this.svgArrow) {
|
|
arrowWidth = this.positionSVGArrow(
|
|
imageWidth + xPadding,
|
|
height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,
|
|
);
|
|
} else {
|
|
arrowWidth = dom.getTextWidth(this.arrow as SVGTSpanElement);
|
|
}
|
|
this.size_ = new Size(imageWidth + arrowWidth + xPadding * 2, height);
|
|
|
|
let arrowX = 0;
|
|
if (block.RTL) {
|
|
const imageX = xPadding + arrowWidth;
|
|
imageElement.setAttribute('x', `${imageX}`);
|
|
} else {
|
|
arrowX = imageWidth + arrowWidth;
|
|
this.getTextElement().setAttribute('text-anchor', 'end');
|
|
imageElement.setAttribute('x', `${xPadding}`);
|
|
}
|
|
imageElement.setAttribute('y', String(height / 2 - imageHeight / 2));
|
|
|
|
this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth);
|
|
|
|
if (imageElement.id === '') {
|
|
imageElement.id = idGenerator.getNextUniqueId();
|
|
const element = this.getFocusableElement();
|
|
aria.setState(element, aria.State.ACTIVEDESCENDANT, imageElement.id);
|
|
}
|
|
|
|
aria.setRole(imageElement, aria.Role.IMAGE);
|
|
aria.setState(imageElement, aria.State.LABEL, imageJson.alt);
|
|
}
|
|
|
|
/** Renders the selected option, which must be text. */
|
|
private renderSelectedText() {
|
|
// Retrieves the selected option to display through getText_.
|
|
this.getTextContent().nodeValue = this.getDisplayText_();
|
|
const textElement = this.getTextElement();
|
|
dom.addClass(textElement, 'blocklyDropdownText');
|
|
textElement.setAttribute('text-anchor', 'start');
|
|
// The field's text should be visible to readers since it will be read out
|
|
// as static text as part of the combobox (per the ARIA combobox pattern).
|
|
if (textElement.id === '') {
|
|
textElement.id = idGenerator.getNextUniqueId();
|
|
const element = this.getFocusableElement();
|
|
aria.setState(element, aria.State.ACTIVEDESCENDANT, textElement.id);
|
|
}
|
|
aria.setState(textElement, aria.State.HIDDEN, false);
|
|
|
|
// Height and width include the border rect.
|
|
const hasBorder = !!this.borderRect_;
|
|
const height = Math.max(
|
|
hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,
|
|
this.getConstants()!.FIELD_TEXT_HEIGHT,
|
|
);
|
|
const textWidth = dom.getTextWidth(this.getTextElement());
|
|
const xPadding = hasBorder
|
|
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
|
|
: 0;
|
|
let arrowWidth = 0;
|
|
if (this.svgArrow) {
|
|
arrowWidth = this.positionSVGArrow(
|
|
textWidth + xPadding,
|
|
height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,
|
|
);
|
|
}
|
|
this.size_ = new Size(textWidth + arrowWidth + xPadding * 2, height);
|
|
|
|
this.positionTextElement_(xPadding, textWidth);
|
|
}
|
|
|
|
/**
|
|
* Position a drop-down arrow at the appropriate location at render-time.
|
|
*
|
|
* @param x X position the arrow is being rendered at, in px.
|
|
* @param y Y position the arrow is being rendered at, in px.
|
|
* @returns Amount of space the arrow is taking up, in px.
|
|
*/
|
|
private positionSVGArrow(x: number, y: number): number {
|
|
if (!this.svgArrow) {
|
|
return 0;
|
|
}
|
|
const block = this.getSourceBlock();
|
|
if (!block) {
|
|
throw new UnattachedFieldError();
|
|
}
|
|
const hasBorder = !!this.borderRect_;
|
|
const xPadding = hasBorder
|
|
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
|
|
: 0;
|
|
const textPadding = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_PADDING;
|
|
const svgArrowSize = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE;
|
|
const arrowX = block.RTL ? xPadding : x + textPadding;
|
|
this.svgArrow.setAttribute(
|
|
'transform',
|
|
'translate(' + arrowX + ',' + y + ')',
|
|
);
|
|
return svgArrowSize + textPadding;
|
|
}
|
|
|
|
/**
|
|
* Use the `getText_` developer hook to override the field's text
|
|
* representation. Get the selected option text. If the selected option is
|
|
* an image we return the image alt text. If the selected option is
|
|
* an HTMLElement, return the title, ariaLabel, or innerText of the
|
|
* element.
|
|
*
|
|
* If you use HTMLElement options in Node.js and call this function,
|
|
* ensure that you are supplying an implementation of HTMLElement,
|
|
* such as through jsdom-global.
|
|
*
|
|
* @returns Selected option text.
|
|
*/
|
|
protected override getText_(): string | null {
|
|
if (!this.selectedOption) {
|
|
return null;
|
|
}
|
|
return this.computeHumanReadableText(this.selectedOption);
|
|
}
|
|
|
|
private computeHumanReadableText(menuOption: MenuOption): string | null {
|
|
const option = menuOption[0];
|
|
if (isImageProperties(option)) {
|
|
return option.alt;
|
|
} else if (
|
|
typeof HTMLElement !== 'undefined' &&
|
|
option instanceof HTMLElement
|
|
) {
|
|
return option.title ?? option.ariaLabel ?? option.innerText;
|
|
} else if (typeof option === 'string') {
|
|
return option;
|
|
}
|
|
|
|
console.warn(
|
|
"Can't get text for existing dropdown option. If " +
|
|
"you're using HTMLElement dropdown options in node, ensure you're " +
|
|
'using jsdom-global or similar.',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Construct a FieldDropdown from a JSON arg object.
|
|
*
|
|
* @param options A JSON object with options (options).
|
|
* @returns The new field instance.
|
|
* @nocollapse
|
|
* @internal
|
|
*/
|
|
static override fromJson(
|
|
options: FieldDropdownFromJsonConfig,
|
|
): FieldDropdown {
|
|
if (!options.options) {
|
|
throw new Error(
|
|
'options are required for the dropdown field. The ' +
|
|
'options property must be assigned an array of ' +
|
|
'[humanReadableValue, languageNeutralValue, opt_ariaLabel] tuples.',
|
|
);
|
|
}
|
|
// `this` might be a subclass of FieldDropdown if that class doesn't
|
|
// override the static fromJson method.
|
|
return new this(options.options, undefined, options);
|
|
}
|
|
|
|
/**
|
|
* Factor out common words in statically defined options.
|
|
* Create prefix and/or suffix labels.
|
|
*/
|
|
protected trimOptions(options: MenuOption[]): {
|
|
options: MenuOption[];
|
|
prefix?: string;
|
|
suffix?: string;
|
|
} {
|
|
let hasNonTextContent = false;
|
|
const trimmedOptions = options.map((option): MenuOption => {
|
|
if (option === FieldDropdown.SEPARATOR) {
|
|
hasNonTextContent = true;
|
|
return option;
|
|
}
|
|
|
|
const [label, value, opt_ariaLabel] = option;
|
|
if (typeof label === 'string') {
|
|
return [parsing.replaceMessageReferences(label), value, opt_ariaLabel];
|
|
}
|
|
|
|
hasNonTextContent = true;
|
|
// Copy the image properties so they're not influenced by the original.
|
|
// NOTE: No need to deep copy since image properties are only 1 level deep.
|
|
const imageLabel = isImageProperties(label)
|
|
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
|
|
: label;
|
|
return [imageLabel, value, opt_ariaLabel];
|
|
});
|
|
|
|
if (hasNonTextContent || options.length < 2) {
|
|
return {options: trimmedOptions};
|
|
}
|
|
|
|
const stringOptions = trimmedOptions as [string, string, string?][];
|
|
const stringLabels = stringOptions.map(([label]) => label);
|
|
|
|
const shortest = utilsString.shortestStringLength(stringLabels);
|
|
const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest);
|
|
const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest);
|
|
|
|
if (
|
|
(!prefixLength && !suffixLength) ||
|
|
shortest <= prefixLength + suffixLength
|
|
) {
|
|
// One or more strings will entirely vanish if we proceed. Abort.
|
|
return {options: stringOptions};
|
|
}
|
|
|
|
const prefix = prefixLength
|
|
? stringLabels[0].substring(0, prefixLength - 1)
|
|
: undefined;
|
|
const suffix = suffixLength
|
|
? stringLabels[0].substr(1 - suffixLength)
|
|
: undefined;
|
|
return {
|
|
options: this.applyTrim(stringOptions, prefixLength, suffixLength),
|
|
prefix,
|
|
suffix,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Use the calculated prefix and suffix lengths to trim all of the options in
|
|
* the given array.
|
|
*
|
|
* @param options Array of option tuples:
|
|
* (human-readable text or image, language-neutral name).
|
|
* @param prefixLength The length of the common prefix.
|
|
* @param suffixLength The length of the common suffix
|
|
* @returns A new array with all of the option text trimmed.
|
|
*/
|
|
private applyTrim(
|
|
options: [string, string, string?][],
|
|
prefixLength: number,
|
|
suffixLength: number,
|
|
): MenuOption[] {
|
|
return options.map(([text, value, opt_ariaLabel]) => [
|
|
text.substring(prefixLength, text.length - suffixLength),
|
|
value,
|
|
opt_ariaLabel,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Validates the data structure to be processed as an options list.
|
|
*
|
|
* @param options The proposed dropdown options.
|
|
* @throws {TypeError} If proposed options are incorrectly structured.
|
|
*/
|
|
protected validateOptions(options: MenuOption[]) {
|
|
if (!Array.isArray(options)) {
|
|
throw TypeError('FieldDropdown options must be an array.');
|
|
}
|
|
if (!options.length) {
|
|
throw TypeError('FieldDropdown options must not be an empty array.');
|
|
}
|
|
let foundError = false;
|
|
for (let i = 0; i < options.length; i++) {
|
|
const option = options[i];
|
|
if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) {
|
|
foundError = true;
|
|
console.error(
|
|
`Invalid option[${i}]: Each FieldDropdown option must be an array or
|
|
the string literal 'separator'. Found: ${option}`,
|
|
);
|
|
} else if (typeof option[1] !== 'string') {
|
|
foundError = true;
|
|
console.error(
|
|
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
|
|
Found ${option[1]} in: ${option}`,
|
|
);
|
|
} else if (
|
|
option[0] &&
|
|
typeof option[0] !== 'string' &&
|
|
!isImageProperties(option[0]) &&
|
|
!(
|
|
typeof HTMLElement !== 'undefined' && option[0] instanceof HTMLElement
|
|
)
|
|
) {
|
|
foundError = true;
|
|
console.error(
|
|
`Invalid option[${i}]: Each FieldDropdown option must have a string
|
|
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
|
|
);
|
|
}
|
|
}
|
|
if (foundError) {
|
|
throw TypeError('Found invalid FieldDropdown options.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether or not an object conforms to the ImageProperties interface.
|
|
*
|
|
* @param obj The object to test.
|
|
* @returns True if the object conforms to ImageProperties, otherwise false.
|
|
*/
|
|
function isImageProperties(obj: any): obj is ImageProperties {
|
|
return (
|
|
obj &&
|
|
typeof obj === 'object' &&
|
|
'src' in obj &&
|
|
typeof obj.src === 'string' &&
|
|
'alt' in obj &&
|
|
typeof obj.alt === 'string' &&
|
|
'width' in obj &&
|
|
typeof obj.width === 'number' &&
|
|
'height' in obj &&
|
|
typeof obj.height === 'number'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Definition of a human-readable image dropdown option.
|
|
*/
|
|
export interface ImageProperties {
|
|
src: string;
|
|
alt: string;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
/**
|
|
* An individual option in the dropdown menu. Can be either the string literal
|
|
* `separator` for a menu separator item, or an array for normal action menu
|
|
* items. In the latter case, the first element is the human-readable value
|
|
* (text, ImageProperties object, or HTML element), and the second element is
|
|
* the language-neutral value.
|
|
*/
|
|
export type MenuOption =
|
|
| [string | ImageProperties | HTMLElement, string, string?]
|
|
| 'separator';
|
|
|
|
/**
|
|
* A function that generates an array of menu options for FieldDropdown
|
|
* or its descendants.
|
|
*/
|
|
export type MenuGeneratorFunction = (this: FieldDropdown) => MenuOption[];
|
|
|
|
/**
|
|
* Either an array of menu options or a function that generates an array of
|
|
* menu options for FieldDropdown or its descendants.
|
|
*/
|
|
export type MenuGenerator = MenuOption[] | MenuGeneratorFunction;
|
|
|
|
/**
|
|
* Config options for the dropdown field.
|
|
*/
|
|
export type FieldDropdownConfig = FieldConfig;
|
|
|
|
/**
|
|
* fromJson config for the dropdown field.
|
|
*/
|
|
export interface FieldDropdownFromJsonConfig extends FieldDropdownConfig {
|
|
options?: MenuOption[];
|
|
}
|
|
|
|
/**
|
|
* A function that is called to validate changes to the field's value before
|
|
* they are set.
|
|
*
|
|
* @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}
|
|
* @param newValue The value to be validated.
|
|
* @returns One of three instructions for setting the new value: `T`, `null`,
|
|
* or `undefined`.
|
|
*
|
|
* - `T` to set this function's returned value instead of `newValue`.
|
|
*
|
|
* - `null` to invoke `doValueInvalid_` and not set a value.
|
|
*
|
|
* - `undefined` to set `newValue` as is.
|
|
*/
|
|
export type FieldDropdownValidator = FieldValidator<string>;
|
|
|
|
fieldRegistry.register('field_dropdown', FieldDropdown);
|