Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/block/src/fields/dropdown.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions packages/block/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,6 +193,7 @@ function setupEnvironment() {
registerFieldAngle();
registerFieldButton();
registerFieldColourSlider();
registerFieldDropdown();
registerFieldMatrix();
registerFieldNote();
registerFieldNumber();
Expand Down Expand Up @@ -261,6 +263,7 @@ export {
FieldAngle,
FieldButton,
FieldColourSlider,
FieldDropdown,
FieldMatrix,
FieldNote,
FieldNumber,
Expand Down
87 changes: 87 additions & 0 deletions packages/block/tests/unit/field_dropdown.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof FieldDropdown>
);

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'
});
});
});
});
Loading