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
73 changes: 57 additions & 16 deletions core/menuitem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
// Former goog.module ID: Blockly.MenuItem

import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';

/**
Expand Down Expand Up @@ -74,12 +73,6 @@ export class MenuItem {

const content = document.createElement('div');
content.className = 'blocklyMenuItemContent';
// Add a checkbox for checkable menu items.
if (this.checkable) {
const checkbox = document.createElement('div');
checkbox.className = 'blocklyMenuItemCheckbox ';
content.appendChild(checkbox);
}

let contentDom: Node = this.content as HTMLElement;
if (typeof this.content === 'string') {
Expand All @@ -88,6 +81,11 @@ export class MenuItem {
content.appendChild(contentDom);
element.appendChild(content);

// Add a checkbox for checkable menu items.
if (this.checkable) {
this.toggleCheckbox(true);
}

// Initialize ARIA role and state.
if (this.roleName) {
aria.setRole(element, this.roleName);
Expand Down Expand Up @@ -145,6 +143,7 @@ export class MenuItem {
*/
setRightToLeft(rtl: boolean) {
this.rightToLeft = rtl;
this.getElement()?.classList.toggle('blocklyMenuItemRtl', this.rightToLeft);
}

/**
Expand All @@ -166,6 +165,12 @@ export class MenuItem {
*/
setCheckable(checkable: boolean) {
this.checkable = checkable;

if (!this.checkable) {
this.setChecked(false);
}

this.toggleCheckbox(checkable);
}

/**
Expand All @@ -175,7 +180,14 @@ export class MenuItem {
* @internal
*/
setChecked(checked: boolean) {
if (checked && !this.checkable) return;

this.checked = checked;
const element = this.getElement();
if (element) {
element.classList.toggle('blocklyMenuItemSelected', this.checked);
aria.setState(element, aria.State.SELECTED, this.checked);
}
}

/**
Expand All @@ -186,15 +198,10 @@ export class MenuItem {
*/
setHighlighted(highlight: boolean) {
this.highlight = highlight;
const el = this.getElement();
if (el && this.isEnabled()) {
const name = 'blocklyMenuItemHighlight';
if (highlight) {
dom.addClass(el, name);
} else {
dom.removeClass(el, name);
}
}
this.getElement()?.classList.toggle(
'blocklyMenuItemHighlight',
this.highlight,
);
}

/**
Expand All @@ -215,6 +222,11 @@ export class MenuItem {
*/
setEnabled(enabled: boolean) {
this.enabled = enabled;
const element = this.getElement();
if (element) {
element.classList.toggle('blocklyMenuItemDisabled', !this.enabled);
aria.setState(element, aria.State.DISABLED, !this.enabled);
}
}

/**
Expand Down Expand Up @@ -243,4 +255,33 @@ export class MenuItem {
onAction(fn: (p1: MenuItem, menuSelectEvent: Event) => void, obj: object) {
this.actionHandler = fn.bind(obj);
}

/**
* Adds or removes the checkmark indicator on this menu item.
* The indicator is present even if this menu item is not checked, as long
* as it is checkable; its visibility is controlled with CSS.
*
* @param add True to add the checkmark indicator, false to remove it.
*/
private toggleCheckbox(add: boolean) {
if (add) {
if (
this.getElement()?.querySelector(
'.blocklyMenuItemContent .blocklyMenuItemCheckbox',
)
) {
return;
}

const checkbox = document.createElement('div');
checkbox.className = 'blocklyMenuItemCheckbox ';
this.getElement()
?.querySelector('.blocklyMenuItemContent')
?.prepend(checkbox);
} else {
this.getElement()
?.querySelector('.blocklyMenuItemContent .blocklyMenuItemCheckbox')
?.remove();
}
}
}
1 change: 1 addition & 0 deletions tests/mocha/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@
import './blocks/lists_test.js';
import './blocks/logic_ternary_test.js';
import './blocks/loops_test.js';
import './menu_item_test.js';
import './metrics_test.js';
import './mutator_test.js';
import './names_test.js';
Expand Down
168 changes: 168 additions & 0 deletions tests/mocha/menu_item_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/

import {assert} from '../../node_modules/chai/index.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';

suite('Menu items', function () {
setup(function () {
sharedTestSetup.call(this);
this.menuItem = new Blockly.MenuItem('Hello World');
this.menuItem.createDom();
});

teardown(function () {
sharedTestTeardown.call(this);
});

test('can be RTL', function () {
this.menuItem.setRightToLeft(true);
assert.isTrue(
this.menuItem.getElement().classList.contains('blocklyMenuItemRtl'),
);
});

test('can be LTR', function () {
this.menuItem.setRightToLeft(false);
assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemRtl'),
);
});

test('can be checked', function () {
this.menuItem.setCheckable(true);
this.menuItem.setChecked(true);
assert.isTrue(
this.menuItem.getElement().classList.contains('blocklyMenuItemSelected'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-selected'),
'true',
);
});

test('cannot be checked when designated as uncheckable', function () {
this.menuItem.setCheckable(false);
this.menuItem.setChecked(true);
assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemSelected'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-selected'),
'false',
);
});

test('can be unchecked', function () {
this.menuItem.setCheckable(true);
this.menuItem.setChecked(false);
assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemSelected'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-selected'),
'false',
);
});

test('uncheck themselves when designated as non-checkable', function () {
this.menuItem.setChecked(true);
this.menuItem.setCheckable(false);

assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemSelected'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-selected'),
'false',
);
});

test('do not check themselves when designated as checkable', function () {
this.menuItem.setChecked(false);
this.menuItem.setCheckable(true);

assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemSelected'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-selected'),
'false',
);
});

test('adds a checkbox when designated as checkable', function () {
assert.isNull(
this.menuItem.getElement().querySelector('.blocklyMenuItemCheckbox'),
);
this.menuItem.setCheckable(true);
assert.isNotNull(
this.menuItem.getElement().querySelector('.blocklyMenuItemCheckbox'),
);
});

test('removes the checkbox when designated as uncheckable', function () {
this.menuItem.setCheckable(true);
assert.isNotNull(
this.menuItem.getElement().querySelector('.blocklyMenuItemCheckbox'),
);
this.menuItem.setCheckable(false);
assert.isNull(
this.menuItem.getElement().querySelector('.blocklyMenuItemCheckbox'),
);
});

test('can be highlighted', function () {
this.menuItem.setHighlighted(true);
assert.isTrue(
this.menuItem.getElement().classList.contains('blocklyMenuItemHighlight'),
);
});

test('can be unhighlighted', function () {
this.menuItem.setHighlighted(false);
assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemHighlight'),
);
});

test('can be enabled', function () {
this.menuItem.setEnabled(true);
assert.isTrue(this.menuItem.isEnabled());
assert.isFalse(
this.menuItem.getElement().classList.contains('blocklyMenuItemDisabled'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-disabled'),
'false',
);
});

test('can be disabled', function () {
this.menuItem.setEnabled(false);
assert.isFalse(this.menuItem.isEnabled());
assert.isTrue(
this.menuItem.getElement().classList.contains('blocklyMenuItemDisabled'),
);
assert.equal(
this.menuItem.getElement().getAttribute('aria-disabled'),
'true',
);
});

test('invokes its action callback', function () {
let called = false;
const callback = () => {
called = true;
};
this.menuItem.onAction(callback, this);
this.menuItem.performAction(new Event('click'));
assert.isTrue(called);
});
});
Loading