diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 525dbf01b70..e3a2a3abec8 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -334,11 +334,11 @@ export class FieldDropdown extends Field { const [label, value] = option; const content = (() => { - if (typeof label === 'object') { + if (isImageProperties(label)) { // Convert ImageProperties to an HTMLImageElement. - const image = new Image(label['width'], label['height']); - image.src = label['src']; - image.alt = label['alt'] || ''; + const image = new Image(label.width, label.height); + image.src = label.src; + image.alt = label.alt; return image; } return label; @@ -499,7 +499,7 @@ export class FieldDropdown extends Field { // Show correct element. const option = this.selectedOption && this.selectedOption[0]; - if (option && typeof option === 'object') { + if (isImageProperties(option)) { this.renderSelectedImage(option); } else { this.renderSelectedText(); @@ -637,8 +637,10 @@ export class FieldDropdown extends Field { return null; } const option = this.selectedOption[0]; - if (typeof option === 'object') { - return option['alt']; + if (isImageProperties(option)) { + return option.alt; + } else if (option instanceof HTMLElement) { + return option.title ?? option.ariaLabel ?? option.innerText; } return option; } @@ -687,10 +689,9 @@ export class FieldDropdown extends Field { hasImages = 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 = - label.alt !== null - ? {...label, alt: parsing.replaceMessageReferences(label.alt)} - : {...label}; + const imageLabel = isImageProperties(label) + ? {...label, alt: parsing.replaceMessageReferences(label.alt)} + : {...label}; return [imageLabel, value]; }); @@ -776,12 +777,13 @@ export class FieldDropdown extends Field { } else if ( option[0] && typeof option[0] !== 'string' && - typeof option[0].src !== 'string' + !isImageProperties(option[0]) && + !(option[0] instanceof HTMLElement) ) { foundError = true; console.error( `Invalid option[${i}]: Each FieldDropdown option must have a string - label or image description. Found ${option[0]} in: ${option}`, + label, image description, or HTML element. Found ${option[0]} in: ${option}`, ); } } @@ -791,6 +793,27 @@ export class FieldDropdown extends Field { } } +/** + * 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. */ @@ -805,9 +828,12 @@ export interface ImageProperties { * 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 or image), and the second element is the language-neutral value. + * (text, ImageProperties object, or HTML element), and the second element is + * the language-neutral value. */ -export type MenuOption = [string | ImageProperties, string] | 'separator'; +export type MenuOption = + | [string | ImageProperties | HTMLElement, string] + | 'separator'; /** * A function that generates an array of menu options for FieldDropdown diff --git a/tests/mocha/field_dropdown_test.js b/tests/mocha/field_dropdown_test.js index 61deaf47f39..2ed7098fc9f 100644 --- a/tests/mocha/field_dropdown_test.js +++ b/tests/mocha/field_dropdown_test.js @@ -92,9 +92,9 @@ suite('Dropdown Fields', function () { expectedText: 'a', args: [ [ - [{src: 'scrA', alt: 'a'}, 'A'], - [{src: 'scrB', alt: 'b'}, 'B'], - [{src: 'scrC', alt: 'c'}, 'C'], + [{src: 'scrA', alt: 'a', width: 10, height: 10}, 'A'], + [{src: 'scrB', alt: 'b', width: 10, height: 10}, 'B'], + [{src: 'scrC', alt: 'c', width: 10, height: 10}, 'C'], ], ], }, @@ -121,9 +121,9 @@ suite('Dropdown Fields', function () { args: [ () => { return [ - [{src: 'scrA', alt: 'a'}, 'A'], - [{src: 'scrB', alt: 'b'}, 'B'], - [{src: 'scrC', alt: 'c'}, 'C'], + [{src: 'scrA', alt: 'a', width: 10, height: 10}, 'A'], + [{src: 'scrB', alt: 'b', width: 10, height: 10}, 'B'], + [{src: 'scrC', alt: 'c', width: 10, height: 10}, 'C'], ]; }, ], diff --git a/tests/mocha/json_test.js b/tests/mocha/json_test.js index e9e465c65bc..471d2fb9711 100644 --- a/tests/mocha/json_test.js +++ b/tests/mocha/json_test.js @@ -256,12 +256,6 @@ suite('JSON Block Definitions', function () { 'alt': '%{BKY_ALT_TEXT}', }; const VALUE1 = 'VALUE1'; - const IMAGE2 = { - 'width': 90, - 'height': 123, - 'src': 'http://image2.src', - }; - const VALUE2 = 'VALUE2'; Blockly.defineBlocksWithJsonArray([ { @@ -274,7 +268,6 @@ suite('JSON Block Definitions', function () { 'options': [ [IMAGE0, VALUE0], [IMAGE1, VALUE1], - [IMAGE2, VALUE2], ], }, ], @@ -305,11 +298,6 @@ suite('JSON Block Definitions', function () { assertImageEquals(IMAGE1, image1); assert.equal(image1.alt, IMAGE1_ALT_TEXT); // Via Msg reference assert.equal(VALUE1, options[1][1]); - - const image2 = options[2][0]; - assertImageEquals(IMAGE1, image1); - assert.notExists(image2.alt); // No alt specified. - assert.equal(VALUE2, options[2][1]); }); }); });