feat: Allow for HTML elements in dropdown field menus. (#8889)

* feat: Allow for HTML elements in dropdown field menus.

* refactor: Use dot access.
This commit is contained in:
Aaron Dodson
2025-04-15 14:34:38 -07:00
committed by GitHub
parent acca9ea83f
commit fd9263ac51
3 changed files with 47 additions and 33 deletions

View File

@@ -332,11 +332,11 @@ export class FieldDropdown extends Field<string> {
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;
@@ -497,7 +497,7 @@ export class FieldDropdown extends Field<string> {
// Show correct element.
const option = this.selectedOption && this.selectedOption[0];
if (option && typeof option === 'object') {
if (isImageProperties(option)) {
this.renderSelectedImage(option);
} else {
this.renderSelectedText();
@@ -635,8 +635,10 @@ export class FieldDropdown extends Field<string> {
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;
}
@@ -685,10 +687,9 @@ export class FieldDropdown extends Field<string> {
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];
});
@@ -774,12 +775,13 @@ export class FieldDropdown extends Field<string> {
} 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}`,
);
}
}
@@ -789,6 +791,27 @@ export class FieldDropdown extends Field<string> {
}
}
/**
* 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.
*/
@@ -803,9 +826,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

View File

@@ -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'],
];
},
],

View File

@@ -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]);
});
});
});