Skip to content

Commit fa4fce5

Browse files
authored
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.
1 parent 0ed6c82 commit fa4fce5

File tree

8 files changed

+185
-47
lines changed

8 files changed

+185
-47
lines changed

core/contextmenu.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
import {EventType} from './events/type.js';
1919
import * as eventUtils from './events/utils.js';
2020
import {Menu} from './menu.js';
21+
import {MenuSeparator} from './menu_separator.js';
2122
import {MenuItem} from './menuitem.js';
2223
import * as serializationBlocks from './serialization/blocks.js';
2324
import * as aria from './utils/aria.js';
@@ -111,6 +112,11 @@ function populate_(
111112
menu.setRole(aria.Role.MENU);
112113
for (let i = 0; i < options.length; i++) {
113114
const option = options[i];
115+
if (option.separator) {
116+
menu.addChild(new MenuSeparator());
117+
continue;
118+
}
119+
114120
const menuItem = new MenuItem(option.text);
115121
menuItem.setRightToLeft(rtl);
116122
menuItem.setRole(aria.Role.MENUITEM);

core/contextmenu_registry.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,37 @@ export class ContextMenuRegistry {
8787
const menuOptions: ContextMenuOption[] = [];
8888
for (const item of this.registeredItems.values()) {
8989
if (scopeType === item.scopeType) {
90-
const precondition = item.preconditionFn(scope);
91-
if (precondition !== 'hidden') {
90+
let menuOption:
91+
| ContextMenuRegistry.CoreContextMenuOption
92+
| ContextMenuRegistry.SeparatorContextMenuOption
93+
| ContextMenuRegistry.ActionContextMenuOption;
94+
menuOption = {
95+
scope,
96+
weight: item.weight,
97+
};
98+
99+
if (item.separator) {
100+
menuOption = {
101+
...menuOption,
102+
separator: true,
103+
};
104+
} else {
105+
const precondition = item.preconditionFn(scope);
106+
if (precondition === 'hidden') continue;
107+
92108
const displayText =
93109
typeof item.displayText === 'function'
94110
? item.displayText(scope)
95111
: item.displayText;
96-
const menuOption: ContextMenuOption = {
112+
menuOption = {
113+
...menuOption,
97114
text: displayText,
98-
enabled: precondition === 'enabled',
99115
callback: item.callback,
100-
scope,
101-
weight: item.weight,
116+
enabled: precondition === 'enabled',
102117
};
103-
menuOptions.push(menuOption);
104118
}
119+
120+
menuOptions.push(menuOption);
105121
}
106122
}
107123
menuOptions.sort(function (a, b) {
@@ -134,27 +150,57 @@ export namespace ContextMenuRegistry {
134150
}
135151

136152
/**
137-
* A menu item as entered in the registry.
153+
* Fields common to all context menu registry items.
138154
*/
139-
export interface RegistryItem {
155+
interface CoreRegistryItem {
156+
scopeType: ScopeType;
157+
weight: number;
158+
id: string;
159+
}
160+
161+
/**
162+
* A representation of a normal, clickable menu item in the registry.
163+
*/
164+
interface ActionRegistryItem extends CoreRegistryItem {
140165
/**
141166
* @param scope Object that provides a reference to the thing that had its
142167
* context menu opened.
143168
* @param e The original event that triggered the context menu to open. Not
144169
* the event that triggered the click on the option.
145170
*/
146171
callback: (scope: Scope, e: PointerEvent) => void;
147-
scopeType: ScopeType;
148172
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
149173
preconditionFn: (p1: Scope) => string;
174+
separator?: never;
175+
}
176+
177+
/**
178+
* A representation of a menu separator item in the registry.
179+
*/
180+
interface SeparatorRegistryItem extends CoreRegistryItem {
181+
separator: true;
182+
callback?: never;
183+
displayText?: never;
184+
preconditionFn?: never;
185+
}
186+
187+
/**
188+
* A menu item as entered in the registry.
189+
*/
190+
export type RegistryItem = ActionRegistryItem | SeparatorRegistryItem;
191+
192+
/**
193+
* Fields common to all context menu items as used by contextmenu.ts.
194+
*/
195+
export interface CoreContextMenuOption {
196+
scope: Scope;
150197
weight: number;
151-
id: string;
152198
}
153199

154200
/**
155-
* A menu item as presented to contextmenu.js.
201+
* A representation of a normal, clickable menu item in contextmenu.ts.
156202
*/
157-
export interface ContextMenuOption {
203+
export interface ActionContextMenuOption extends CoreContextMenuOption {
158204
text: string | HTMLElement;
159205
enabled: boolean;
160206
/**
@@ -164,10 +210,26 @@ export namespace ContextMenuRegistry {
164210
* the event that triggered the click on the option.
165211
*/
166212
callback: (scope: Scope, e: PointerEvent) => void;
167-
scope: Scope;
168-
weight: number;
213+
separator?: never;
169214
}
170215

216+
/**
217+
* A representation of a menu separator item in contextmenu.ts.
218+
*/
219+
export interface SeparatorContextMenuOption extends CoreContextMenuOption {
220+
separator: true;
221+
text?: never;
222+
enabled?: never;
223+
callback?: never;
224+
}
225+
226+
/**
227+
* A menu item as presented to contextmenu.ts.
228+
*/
229+
export type ContextMenuOption =
230+
| ActionContextMenuOption
231+
| SeparatorContextMenuOption;
232+
171233
/**
172234
* A subset of ContextMenuOption corresponding to what was publicly
173235
* documented. ContextMenuOption should be preferred for new code.
@@ -176,6 +238,7 @@ export namespace ContextMenuRegistry {
176238
text: string;
177239
enabled: boolean;
178240
callback: (p1: Scope) => void;
241+
separator?: never;
179242
}
180243

181244
/**

core/css.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,14 @@ input[type=number] {
461461
margin-right: -24px;
462462
}
463463
464+
.blocklyMenuSeparator {
465+
background-color: #ccc;
466+
height: 1px;
467+
border: 0;
468+
margin-left: 4px;
469+
margin-right: 4px;
470+
}
471+
464472
.blocklyBlockDragSurface, .blocklyAnimationLayer {
465473
position: absolute;
466474
top: 0;

core/extensions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,10 @@ function checkDropdownOptionsInTable(
437437
}
438438

439439
const options = dropdown.getOptions();
440-
for (const [, key] of options) {
440+
for (const option of options) {
441+
if (option === FieldDropdown.SEPARATOR) continue;
442+
443+
const [, key] = option;
441444
if (lookupTable[key] === undefined) {
442445
console.warn(
443446
`No tooltip mapping for value ${key} of field ` +

core/field_dropdown.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from './field.js';
2424
import * as fieldRegistry from './field_registry.js';
2525
import {Menu} from './menu.js';
26+
import {MenuSeparator} from './menu_separator.js';
2627
import {MenuItem} from './menuitem.js';
2728
import * as aria from './utils/aria.js';
2829
import {Coordinate} from './utils/coordinate.js';
@@ -35,14 +36,10 @@ import {Svg} from './utils/svg.js';
3536
* Class for an editable dropdown field.
3637
*/
3738
export class FieldDropdown extends Field<string> {
38-
/** Horizontal distance that a checkmark overhangs the dropdown. */
39-
static CHECKMARK_OVERHANG = 25;
40-
4139
/**
42-
* Maximum height of the dropdown menu, as a percentage of the viewport
43-
* height.
40+
* Magic constant used to represent a separator in a list of dropdown items.
4441
*/
45-
static MAX_MENU_HEIGHT_VH = 0.45;
42+
static readonly SEPARATOR = 'separator';
4643

4744
static ARROW_CHAR = '▾';
4845

@@ -323,7 +320,13 @@ export class FieldDropdown extends Field<string> {
323320
const options = this.getOptions(false);
324321
this.selectedMenuItem = null;
325322
for (let i = 0; i < options.length; i++) {
326-
const [label, value] = options[i];
323+
const option = options[i];
324+
if (option === FieldDropdown.SEPARATOR) {
325+
menu.addChild(new MenuSeparator());
326+
continue;
327+
}
328+
329+
const [label, value] = option;
327330
const content = (() => {
328331
if (typeof label === 'object') {
329332
// Convert ImageProperties to an HTMLImageElement.
@@ -667,7 +670,10 @@ export class FieldDropdown extends Field<string> {
667670
suffix?: string;
668671
} {
669672
let hasImages = false;
670-
const trimmedOptions = options.map(([label, value]): MenuOption => {
673+
const trimmedOptions = options.map((option): MenuOption => {
674+
if (option === FieldDropdown.SEPARATOR) return option;
675+
676+
const [label, value] = option;
671677
if (typeof label === 'string') {
672678
return [parsing.replaceMessageReferences(label), value];
673679
}
@@ -748,28 +754,28 @@ export class FieldDropdown extends Field<string> {
748754
}
749755
let foundError = false;
750756
for (let i = 0; i < options.length; i++) {
751-
const tuple = options[i];
752-
if (!Array.isArray(tuple)) {
757+
const option = options[i];
758+
if (!Array.isArray(option) && option !== FieldDropdown.SEPARATOR) {
753759
foundError = true;
754760
console.error(
755-
`Invalid option[${i}]: Each FieldDropdown option must be an array.
756-
Found: ${tuple}`,
761+
`Invalid option[${i}]: Each FieldDropdown option must be an array or
762+
the string literal 'separator'. Found: ${option}`,
757763
);
758-
} else if (typeof tuple[1] !== 'string') {
764+
} else if (typeof option[1] !== 'string') {
759765
foundError = true;
760766
console.error(
761767
`Invalid option[${i}]: Each FieldDropdown option id must be a string.
762-
Found ${tuple[1]} in: ${tuple}`,
768+
Found ${option[1]} in: ${option}`,
763769
);
764770
} else if (
765-
tuple[0] &&
766-
typeof tuple[0] !== 'string' &&
767-
typeof tuple[0].src !== 'string'
771+
option[0] &&
772+
typeof option[0] !== 'string' &&
773+
typeof option[0].src !== 'string'
768774
) {
769775
foundError = true;
770776
console.error(
771777
`Invalid option[${i}]: Each FieldDropdown option must have a string
772-
label or image description. Found ${tuple[0]} in: ${tuple}`,
778+
label or image description. Found ${option[0]} in: ${option}`,
773779
);
774780
}
775781
}
@@ -790,11 +796,12 @@ export interface ImageProperties {
790796
}
791797

792798
/**
793-
* An individual option in the dropdown menu. The first element is the human-
794-
* readable value (text or image), and the second element is the language-
795-
* neutral value.
799+
* An individual option in the dropdown menu. Can be either the string literal
800+
* `separator` for a menu separator item, or an array for normal action menu
801+
* items. In the latter case, the first element is the human-readable value
802+
* (text or image), and the second element is the language-neutral value.
796803
*/
797-
export type MenuOption = [string | ImageProperties, string];
804+
export type MenuOption = [string | ImageProperties, string] | 'separator';
798805

799806
/**
800807
* A function that generates an array of menu options for FieldDropdown

core/menu.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
// Former goog.module ID: Blockly.Menu
1313

1414
import * as browserEvents from './browser_events.js';
15-
import type {MenuItem} from './menuitem.js';
15+
import type {MenuSeparator} from './menu_separator.js';
16+
import {MenuItem} from './menuitem.js';
1617
import * as aria from './utils/aria.js';
1718
import {Coordinate} from './utils/coordinate.js';
1819
import type {Size} from './utils/size.js';
@@ -23,11 +24,9 @@ import * as style from './utils/style.js';
2324
*/
2425
export class Menu {
2526
/**
26-
* Array of menu items.
27-
* (Nulls are never in the array, but typing the array as nullable prevents
28-
* the compiler from objecting to .indexOf(null))
27+
* Array of menu items and separators.
2928
*/
30-
private readonly menuItems: MenuItem[] = [];
29+
private readonly menuItems: Array<MenuItem | MenuSeparator> = [];
3130

3231
/**
3332
* Coordinates of the mousedown event that caused this menu to open. Used to
@@ -69,10 +68,10 @@ export class Menu {
6968
/**
7069
* Add a new menu item to the bottom of this menu.
7170
*
72-
* @param menuItem Menu item to append.
71+
* @param menuItem Menu item or separator to append.
7372
* @internal
7473
*/
75-
addChild(menuItem: MenuItem) {
74+
addChild(menuItem: MenuItem | MenuSeparator) {
7675
this.menuItems.push(menuItem);
7776
}
7877

@@ -227,7 +226,8 @@ export class Menu {
227226
while (currentElement && currentElement !== menuElem) {
228227
if (currentElement.classList.contains('blocklyMenuItem')) {
229228
// Having found a menu item's div, locate that menu item in this menu.
230-
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) {
229+
const items = this.getMenuItems();
230+
for (let i = 0, menuItem; (menuItem = items[i]); i++) {
231231
if (menuItem.getElement() === currentElement) {
232232
return menuItem;
233233
}
@@ -309,7 +309,8 @@ export class Menu {
309309
private highlightHelper(startIndex: number, delta: number) {
310310
let index = startIndex + delta;
311311
let menuItem;
312-
while ((menuItem = this.menuItems[index])) {
312+
const items = this.getMenuItems();
313+
while ((menuItem = items[index])) {
313314
if (menuItem.isEnabled()) {
314315
this.setHighlighted(menuItem);
315316
break;
@@ -459,4 +460,13 @@ export class Menu {
459460
menuSize.height = menuDom.scrollHeight;
460461
return menuSize;
461462
}
463+
464+
/**
465+
* Returns the action menu items (omitting separators) in this menu.
466+
*
467+
* @returns The MenuItem objects displayed in this menu.
468+
*/
469+
private getMenuItems(): MenuItem[] {
470+
return this.menuItems.filter((item) => item instanceof MenuItem);
471+
}
462472
}

0 commit comments

Comments
 (0)