fix: Make MenuItem methods toggle classes immediately (#9570)

* fix: Make `MenuItem` methods toggle classes immediately

* chore: Add docstring

* fix: Clarify name of `toggleHasCheckbox()`

* fix: Ensure menu items are enabled before highlighting them
This commit is contained in:
Aaron Dodson
2026-01-20 11:19:35 -08:00
committed by GitHub
parent 7411675a5e
commit e4b2f0a746
3 changed files with 235 additions and 15 deletions
+58 -15
View File
@@ -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';
/**
@@ -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') {
@@ -88,6 +81,11 @@ export class MenuItem {
content.appendChild(contentDom);
element.appendChild(content);
// Add a checkbox for checkable menu items.
if (this.checkable) {
this.toggleHasCheckbox(true);
}
// Initialize ARIA role and state.
if (this.roleName) {
aria.setRole(element, this.roleName);
@@ -145,6 +143,7 @@ export class MenuItem {
*/
setRightToLeft(rtl: boolean) {
this.rightToLeft = rtl;
this.getElement()?.classList.toggle('blocklyMenuItemRtl', this.rightToLeft);
}
/**
@@ -166,6 +165,12 @@ export class MenuItem {
*/
setCheckable(checkable: boolean) {
this.checkable = checkable;
if (!this.checkable) {
this.setChecked(false);
}
this.toggleHasCheckbox(checkable);
}
/**
@@ -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);
}
}
/**
@@ -186,14 +198,11 @@ 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);
}
if (this.isEnabled()) {
this.getElement()?.classList.toggle(
'blocklyMenuItemHighlight',
this.highlight,
);
}
}
@@ -215,6 +224,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);
}
}
/**
@@ -243,4 +257,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 toggleHasCheckbox(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
View File
@@ -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';
+176
View File
@@ -0,0 +1,176 @@
/**
* @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('cannot be highlighted if not enabled', function () {
this.menuItem.setEnabled(false);
this.menuItem.setHighlighted(true);
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);
});
});