feat!: Added support for separators in menus. (#8767)

* feat!: Added support for separators in menus.

* chore: Do English gooder.

* fix: Remove menu separators from the DOM during dispose.
This commit is contained in:
Aaron Dodson
2025-02-27 14:00:40 -08:00
committed by GitHub
parent 0ed6c82acc
commit fa4fce5c12
8 changed files with 186 additions and 48 deletions

View File

@@ -18,6 +18,7 @@ import type {
import {EventType} from './events/type.js'; import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js'; import * as eventUtils from './events/utils.js';
import {Menu} from './menu.js'; import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js'; import {MenuItem} from './menuitem.js';
import * as serializationBlocks from './serialization/blocks.js'; import * as serializationBlocks from './serialization/blocks.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
@@ -111,6 +112,11 @@ function populate_(
menu.setRole(aria.Role.MENU); menu.setRole(aria.Role.MENU);
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const option = options[i]; const option = options[i];
if (option.separator) {
menu.addChild(new MenuSeparator());
continue;
}
const menuItem = new MenuItem(option.text); const menuItem = new MenuItem(option.text);
menuItem.setRightToLeft(rtl); menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM); menuItem.setRole(aria.Role.MENUITEM);

View File

@@ -87,21 +87,37 @@ export class ContextMenuRegistry {
const menuOptions: ContextMenuOption[] = []; const menuOptions: ContextMenuOption[] = [];
for (const item of this.registeredItems.values()) { for (const item of this.registeredItems.values()) {
if (scopeType === item.scopeType) { if (scopeType === item.scopeType) {
const precondition = item.preconditionFn(scope); let menuOption:
if (precondition !== 'hidden') { | ContextMenuRegistry.CoreContextMenuOption
| ContextMenuRegistry.SeparatorContextMenuOption
| ContextMenuRegistry.ActionContextMenuOption;
menuOption = {
scope,
weight: item.weight,
};
if (item.separator) {
menuOption = {
...menuOption,
separator: true,
};
} else {
const precondition = item.preconditionFn(scope);
if (precondition === 'hidden') continue;
const displayText = const displayText =
typeof item.displayText === 'function' typeof item.displayText === 'function'
? item.displayText(scope) ? item.displayText(scope)
: item.displayText; : item.displayText;
const menuOption: ContextMenuOption = { menuOption = {
...menuOption,
text: displayText, text: displayText,
enabled: precondition === 'enabled',
callback: item.callback, callback: item.callback,
scope, enabled: precondition === 'enabled',
weight: item.weight,
}; };
menuOptions.push(menuOption);
} }
menuOptions.push(menuOption);
} }
} }
menuOptions.sort(function (a, b) { menuOptions.sort(function (a, b) {
@@ -134,9 +150,18 @@ export namespace ContextMenuRegistry {
} }
/** /**
* A menu item as entered in the registry. * Fields common to all context menu registry items.
*/ */
export interface RegistryItem { interface CoreRegistryItem {
scopeType: ScopeType;
weight: number;
id: string;
}
/**
* A representation of a normal, clickable menu item in the registry.
*/
interface ActionRegistryItem extends CoreRegistryItem {
/** /**
* @param scope Object that provides a reference to the thing that had its * @param scope Object that provides a reference to the thing that had its
* context menu opened. * context menu opened.
@@ -144,17 +169,38 @@ export namespace ContextMenuRegistry {
* the event that triggered the click on the option. * the event that triggered the click on the option.
*/ */
callback: (scope: Scope, e: PointerEvent) => void; callback: (scope: Scope, e: PointerEvent) => void;
scopeType: ScopeType;
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement; displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
preconditionFn: (p1: Scope) => string; preconditionFn: (p1: Scope) => string;
weight: number; separator?: never;
id: string;
} }
/** /**
* A menu item as presented to contextmenu.js. * A representation of a menu separator item in the registry.
*/ */
export interface ContextMenuOption { interface SeparatorRegistryItem extends CoreRegistryItem {
separator: true;
callback?: never;
displayText?: never;
preconditionFn?: never;
}
/**
* A menu item as entered in the registry.
*/
export type RegistryItem = ActionRegistryItem | SeparatorRegistryItem;
/**
* Fields common to all context menu items as used by contextmenu.ts.
*/
export interface CoreContextMenuOption {
scope: Scope;
weight: number;
}
/**
* A representation of a normal, clickable menu item in contextmenu.ts.
*/
export interface ActionContextMenuOption extends CoreContextMenuOption {
text: string | HTMLElement; text: string | HTMLElement;
enabled: boolean; enabled: boolean;
/** /**
@@ -164,10 +210,26 @@ export namespace ContextMenuRegistry {
* the event that triggered the click on the option. * the event that triggered the click on the option.
*/ */
callback: (scope: Scope, e: PointerEvent) => void; callback: (scope: Scope, e: PointerEvent) => void;
scope: Scope; separator?: never;
weight: number;
} }
/**
* A representation of a menu separator item in contextmenu.ts.
*/
export interface SeparatorContextMenuOption extends CoreContextMenuOption {
separator: true;
text?: never;
enabled?: never;
callback?: never;
}
/**
* A menu item as presented to contextmenu.ts.
*/
export type ContextMenuOption =
| ActionContextMenuOption
| SeparatorContextMenuOption;
/** /**
* A subset of ContextMenuOption corresponding to what was publicly * A subset of ContextMenuOption corresponding to what was publicly
* documented. ContextMenuOption should be preferred for new code. * documented. ContextMenuOption should be preferred for new code.
@@ -176,6 +238,7 @@ export namespace ContextMenuRegistry {
text: string; text: string;
enabled: boolean; enabled: boolean;
callback: (p1: Scope) => void; callback: (p1: Scope) => void;
separator?: never;
} }
/** /**

View File

@@ -461,6 +461,14 @@ input[type=number] {
margin-right: -24px; margin-right: -24px;
} }
.blocklyMenuSeparator {
background-color: #ccc;
height: 1px;
border: 0;
margin-left: 4px;
margin-right: 4px;
}
.blocklyBlockDragSurface, .blocklyAnimationLayer { .blocklyBlockDragSurface, .blocklyAnimationLayer {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -437,7 +437,10 @@ function checkDropdownOptionsInTable(
} }
const options = dropdown.getOptions(); const options = dropdown.getOptions();
for (const [, key] of options) { for (const option of options) {
if (option === FieldDropdown.SEPARATOR) continue;
const [, key] = option;
if (lookupTable[key] === undefined) { if (lookupTable[key] === undefined) {
console.warn( console.warn(
`No tooltip mapping for value ${key} of field ` + `No tooltip mapping for value ${key} of field ` +

View File

@@ -23,6 +23,7 @@ import {
} from './field.js'; } from './field.js';
import * as fieldRegistry from './field_registry.js'; import * as fieldRegistry from './field_registry.js';
import {Menu} from './menu.js'; import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js'; import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
@@ -35,14 +36,10 @@ import {Svg} from './utils/svg.js';
* Class for an editable dropdown field. * Class for an editable dropdown field.
*/ */
export class FieldDropdown extends Field<string> { export class FieldDropdown extends Field<string> {
/** Horizontal distance that a checkmark overhangs the dropdown. */
static CHECKMARK_OVERHANG = 25;
/** /**
* Maximum height of the dropdown menu, as a percentage of the viewport * Magic constant used to represent a separator in a list of dropdown items.
* height.
*/ */
static MAX_MENU_HEIGHT_VH = 0.45; static readonly SEPARATOR = 'separator';
static ARROW_CHAR = '▾'; static ARROW_CHAR = '▾';
@@ -323,7 +320,13 @@ export class FieldDropdown extends Field<string> {
const options = this.getOptions(false); const options = this.getOptions(false);
this.selectedMenuItem = null; this.selectedMenuItem = null;
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const [label, value] = options[i]; const option = options[i];
if (option === FieldDropdown.SEPARATOR) {
menu.addChild(new MenuSeparator());
continue;
}
const [label, value] = option;
const content = (() => { const content = (() => {
if (typeof label === 'object') { if (typeof label === 'object') {
// Convert ImageProperties to an HTMLImageElement. // Convert ImageProperties to an HTMLImageElement.
@@ -667,7 +670,10 @@ export class FieldDropdown extends Field<string> {
suffix?: string; suffix?: string;
} { } {
let hasImages = false; let hasImages = false;
const trimmedOptions = options.map(([label, value]): MenuOption => { const trimmedOptions = options.map((option): MenuOption => {
if (option === FieldDropdown.SEPARATOR) return option;
const [label, value] = option;
if (typeof label === 'string') { if (typeof label === 'string') {
return [parsing.replaceMessageReferences(label), value]; return [parsing.replaceMessageReferences(label), value];
} }
@@ -748,28 +754,28 @@ export class FieldDropdown extends Field<string> {
} }
let foundError = false; let foundError = false;
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const tuple = options[i]; const option = options[i];
if (!Array.isArray(tuple)) { if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option must be an array. `Invalid option[${i}]: Each FieldDropdown option must be an array or
Found: ${tuple}`, the string literal 'separator'. Found: ${option}`,
); );
} else if (typeof tuple[1] !== 'string') { } else if (typeof option[1] !== 'string') {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option id must be a string. `Invalid option[${i}]: Each FieldDropdown option id must be a string.
Found ${tuple[1]} in: ${tuple}`, Found ${option[1]} in: ${option}`,
); );
} else if ( } else if (
tuple[0] && option[0] &&
typeof tuple[0] !== 'string' && typeof option[0] !== 'string' &&
typeof tuple[0].src !== 'string' typeof option[0].src !== 'string'
) { ) {
foundError = true; foundError = true;
console.error( console.error(
`Invalid option[${i}]: Each FieldDropdown option must have a string `Invalid option[${i}]: Each FieldDropdown option must have a string
label or image description. Found ${tuple[0]} in: ${tuple}`, label or image description. Found ${option[0]} in: ${option}`,
); );
} }
} }
@@ -790,11 +796,12 @@ export interface ImageProperties {
} }
/** /**
* An individual option in the dropdown menu. The first element is the human- * An individual option in the dropdown menu. Can be either the string literal
* readable value (text or image), and the second element is the language- * `separator` for a menu separator item, or an array for normal action menu
* neutral value. * items. In the latter case, the first element is the human-readable value
* (text or image), and the second element is the language-neutral value.
*/ */
export type MenuOption = [string | ImageProperties, string]; export type MenuOption = [string | ImageProperties, string] | 'separator';
/** /**
* A function that generates an array of menu options for FieldDropdown * A function that generates an array of menu options for FieldDropdown

View File

@@ -12,7 +12,8 @@
// Former goog.module ID: Blockly.Menu // Former goog.module ID: Blockly.Menu
import * as browserEvents from './browser_events.js'; import * as browserEvents from './browser_events.js';
import type {MenuItem} from './menuitem.js'; import type {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js'; import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js'; import {Coordinate} from './utils/coordinate.js';
import type {Size} from './utils/size.js'; import type {Size} from './utils/size.js';
@@ -23,11 +24,9 @@ import * as style from './utils/style.js';
*/ */
export class Menu { export class Menu {
/** /**
* Array of menu items. * Array of menu items and separators.
* (Nulls are never in the array, but typing the array as nullable prevents
* the compiler from objecting to .indexOf(null))
*/ */
private readonly menuItems: MenuItem[] = []; private readonly menuItems: Array<MenuItem | MenuSeparator> = [];
/** /**
* Coordinates of the mousedown event that caused this menu to open. Used to * Coordinates of the mousedown event that caused this menu to open. Used to
@@ -69,10 +68,10 @@ export class Menu {
/** /**
* Add a new menu item to the bottom of this menu. * Add a new menu item to the bottom of this menu.
* *
* @param menuItem Menu item to append. * @param menuItem Menu item or separator to append.
* @internal * @internal
*/ */
addChild(menuItem: MenuItem) { addChild(menuItem: MenuItem | MenuSeparator) {
this.menuItems.push(menuItem); this.menuItems.push(menuItem);
} }
@@ -227,7 +226,8 @@ export class Menu {
while (currentElement && currentElement !== menuElem) { while (currentElement && currentElement !== menuElem) {
if (currentElement.classList.contains('blocklyMenuItem')) { if (currentElement.classList.contains('blocklyMenuItem')) {
// Having found a menu item's div, locate that menu item in this menu. // Having found a menu item's div, locate that menu item in this menu.
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) { const items = this.getMenuItems();
for (let i = 0, menuItem; (menuItem = items[i]); i++) {
if (menuItem.getElement() === currentElement) { if (menuItem.getElement() === currentElement) {
return menuItem; return menuItem;
} }
@@ -309,7 +309,8 @@ export class Menu {
private highlightHelper(startIndex: number, delta: number) { private highlightHelper(startIndex: number, delta: number) {
let index = startIndex + delta; let index = startIndex + delta;
let menuItem; let menuItem;
while ((menuItem = this.menuItems[index])) { const items = this.getMenuItems();
while ((menuItem = items[index])) {
if (menuItem.isEnabled()) { if (menuItem.isEnabled()) {
this.setHighlighted(menuItem); this.setHighlighted(menuItem);
break; break;
@@ -459,4 +460,13 @@ export class Menu {
menuSize.height = menuDom.scrollHeight; menuSize.height = menuDom.scrollHeight;
return menuSize; return menuSize;
} }
/**
* Returns the action menu items (omitting separators) in this menu.
*
* @returns The MenuItem objects displayed in this menu.
*/
private getMenuItems(): MenuItem[] {
return this.menuItems.filter((item) => item instanceof MenuItem);
}
} }

38
core/menu_separator.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as aria from './utils/aria.js';
/**
* Representation of a section separator in a menu.
*/
export class MenuSeparator {
/**
* DOM element representing this separator in a menu.
*/
private element: HTMLHRElement | null = null;
/**
* Creates the DOM representation of this separator.
*
* @returns An <hr> element.
*/
createDom(): HTMLHRElement {
this.element = document.createElement('hr');
this.element.className = 'blocklyMenuSeparator';
aria.setRole(this.element, aria.Role.SEPARATOR);
return this.element;
}
/**
* Disposes of this separator.
*/
dispose() {
this.element?.remove();
this.element = null;
}
}

View File

@@ -48,6 +48,9 @@ export enum Role {
// ARIA role for a tree item that sometimes may be expanded or collapsed. // ARIA role for a tree item that sometimes may be expanded or collapsed.
TREEITEM = 'treeitem', TREEITEM = 'treeitem',
// ARIA role for a visual separator in e.g. a menu.
SEPARATOR = 'separator',
} }
/** /**