diff --git a/packages/block/src/fields/dropdown.ts b/packages/block/src/fields/dropdown.ts new file mode 100644 index 000000000..c188910a0 --- /dev/null +++ b/packages/block/src/fields/dropdown.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +/** + * A dropdown field that preserves values which are not in its current options. + * Dynamic dropdowns can omit a value that is still valid in the VM, such as a + * sprite target omitted from the current sprite's menu. + */ +export class FieldDropdown extends Blockly.FieldDropdown { + /** + * Accept string values even when the current menu does not contain them. + * @param newValue The value to validate. + * @returns The string value, or null for non-string values. + */ + protected override doClassValidation_(newValue?: string): string | null { + return typeof newValue === 'string' ? newValue : null; + } + + /** + * Display an unmatched value while retaining Blockly's normal rendering for + * matched text, image, and HTMLElement options. + * @returns Text to display for the current value. + */ + protected override getText_(): string | null { + const value = this.getValue(); + if (value === null) { + return super.getText_(); + } + + for (const option of this.getOptions(true)) { + if (option !== 'separator' && option[1] === value) { + return super.getText_(); + } + } + + return value; + } +} + +/** + * Replaces Blockly's field_dropdown registration with the preserving field. + */ +export function registerFieldDropdown() { + Blockly.fieldRegistry.unregister('field_dropdown'); + Blockly.fieldRegistry.register('field_dropdown', FieldDropdown); +} diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index f1bcb5507..7a6502a38 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -13,6 +13,7 @@ import {registerScratchShortcuts} from './shortcut_items'; import {FieldAngle, registerFieldAngle} from './fields/angle'; import {FieldButton, registerFieldButton} from './fields/button'; import {FieldColourSlider, registerFieldColourSlider} from './fields/colour_slider'; +import {FieldDropdown, registerFieldDropdown} from './fields/dropdown'; import {FieldMatrix, registerFieldMatrix} from './fields/matrix'; import {FieldNote, registerFieldNote} from './fields/note'; import {FieldNumber, registerFieldNumber} from './fields/number'; @@ -192,6 +193,7 @@ function setupEnvironment() { registerFieldAngle(); registerFieldButton(); registerFieldColourSlider(); + registerFieldDropdown(); registerFieldMatrix(); registerFieldNote(); registerFieldNumber(); @@ -261,6 +263,7 @@ export { FieldAngle, FieldButton, FieldColourSlider, + FieldDropdown, FieldMatrix, FieldNote, FieldNumber, diff --git a/packages/block/tests/unit/field_dropdown.test.ts b/packages/block/tests/unit/field_dropdown.test.ts new file mode 100644 index 000000000..d45816d3a --- /dev/null +++ b/packages/block/tests/unit/field_dropdown.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, test} from '@jest/globals'; +import * as Blockly from 'blockly/core'; +import {FieldDropdown, registerFieldDropdown} from '../../src/fields/dropdown'; +import {setupSerializationTests} from '../helpers/field'; + +const options: Blockly.MenuOption[] = [ + ['First option', 'FIRST'], + ['Second value', 'SECOND'] +]; + +describe('FieldDropdown', () => { + test('Preserves unknown values and displays them', () => { + const field = new FieldDropdown(options); + + field.setValue('UNKNOWN'); + + expect(field.getValue()).toBe('UNKNOWN'); + expect(field.getText()).toBe('UNKNOWN'); + }); + + test('Uses the option label for valid values', () => { + const field = new FieldDropdown(options); + + field.setValue('SECOND'); + + expect(field.getValue()).toBe('SECOND'); + expect(field.getText()).toBe('Second value'); + }); + + test('Delegates rendering of image option labels', () => { + const field = new FieldDropdown([ + [{ + src: 'image.svg', + alt: 'Image label', + width: 10, + height: 10 + }, 'IMAGE'] + ]); + + expect(field.getText()).toBe('Image label'); + }); + + test('Registers as field_dropdown', () => { + registerFieldDropdown(); + + const field = Blockly.fieldRegistry.fromJson({ + type: 'field_dropdown', + options + }); + + expect(field).toBeInstanceOf(FieldDropdown); + }); + + describe('Serialization', () => { + const context = setupSerializationTests( + FieldDropdown, + 'TARGET', + [options] as unknown as ConstructorParameters + ); + + test('Preserves unknown value', () => { + context.block.setFieldValue('UNKNOWN', 'TARGET'); + + const json = Blockly.serialization.blocks.save(context.block); + + expect(json?.fields).toEqual({ + TARGET: 'UNKNOWN' + }); + }); + + test('Preserves valid value', () => { + context.block.setFieldValue('SECOND', 'TARGET'); + + const json = Blockly.serialization.blocks.save(context.block); + + expect(json?.fields).toEqual({ + TARGET: 'SECOND' + }); + }); + }); +});