diff --git a/core/menuitem.ts b/core/menuitem.ts index b3ae33c5c12..6cb4bc6a319 100644 --- a/core/menuitem.ts +++ b/core/menuitem.ts @@ -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.toggleCheckbox(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.toggleCheckbox(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,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, + ); } /** @@ -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); + } } /** @@ -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(); + } + } } diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 208c2995596..8dd5417ebe0 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -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'; diff --git a/tests/mocha/menu_item_test.js b/tests/mocha/menu_item_test.js new file mode 100644 index 00000000000..7e952557187 --- /dev/null +++ b/tests/mocha/menu_item_test.js @@ -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); + }); +});