Skip to content

Commit 65c3b9e

Browse files
authored
Submenus for Menu Widget (microsoft#52780)
* submenu registration, ignore && mnemonic rendering for now * clean up ActionItems for Menu Widget and submenu * clean up the update actions logic in menubar * fix keyboard navigation for submenus * add terminal menu and update submenu labels * fix missing separator issue * updating some aria labels * bring back action runner to hide menu before running * address feedback from @bpasero
1 parent c9b7b4d commit 65c3b9e

14 files changed

Lines changed: 404 additions & 92 deletions

File tree

src/vs/base/browser/contextmenu.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
*--------------------------------------------------------------------------------------------*/
55
'use strict';
66

7-
import { IAction, IActionRunner, Action } from 'vs/base/common/actions';
7+
import { IAction, IActionRunner } from 'vs/base/common/actions';
88
import { IActionItem } from 'vs/base/browser/ui/actionbar/actionbar';
99
import { TPromise } from 'vs/base/common/winjs.base';
1010
import { ResolvedKeybinding } from 'vs/base/common/keyCodes';
11+
import { SubmenuAction } from 'vs/base/browser/ui/menu/menu';
1112

1213
export interface IEvent {
1314
shiftKey?: boolean;
@@ -16,9 +17,9 @@ export interface IEvent {
1617
metaKey?: boolean;
1718
}
1819

19-
export class ContextSubMenu extends Action {
20+
export class ContextSubMenu extends SubmenuAction {
2021
constructor(label: string, public entries: (ContextSubMenu | IAction)[]) {
21-
super('contextsubmenu', label, '', true);
22+
super(label, entries, 'contextsubmenu');
2223
}
2324
}
2425

src/vs/base/browser/dom.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,7 @@ export const EventType = {
819819
MOUSE_OVER: 'mouseover',
820820
MOUSE_MOVE: 'mousemove',
821821
MOUSE_OUT: 'mouseout',
822+
MOUSE_LEAVE: 'mouseleave',
822823
CONTEXT_MENU: 'contextmenu',
823824
WHEEL: 'wheel',
824825
// Keyboard

src/vs/base/browser/ui/actionbar/actionbar.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454

5555
.monaco-action-bar .action-label {
5656
font-size: 11px;
57-
margin-right: 4px;
57+
margin-right: 4px;
5858
}
5959

6060
.monaco-action-bar .action-label.octicon {

src/vs/base/browser/ui/actionbar/actionbar.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ export class BaseActionItem implements IActionItem {
4343
public _context: any;
4444
public _action: IAction;
4545

46-
static MNEMONIC_REGEX: RegExp = /&&(.)/g;
47-
4846
private _actionRunner: IActionRunner;
4947

5048
constructor(context: any, action: IAction, protected options?: IBaseActionItemOptions) {
@@ -277,11 +275,7 @@ export class ActionItem extends BaseActionItem {
277275

278276
public _updateLabel(): void {
279277
if (this.options.label) {
280-
let label = this.getAction().label;
281-
if (label && this.options.isMenu) {
282-
label = label.replace(BaseActionItem.MNEMONIC_REGEX, '$1\u0332');
283-
}
284-
this.$e.text(label);
278+
this.$e.text(this.getAction().label);
285279
}
286280
}
287281

@@ -565,15 +559,6 @@ export class ActionBar implements IActionRunner {
565559
return this.domNode;
566560
}
567561

568-
private _addMnemonic(action: IAction, actionItemElement: HTMLElement): void {
569-
let matches = BaseActionItem.MNEMONIC_REGEX.exec(action.label);
570-
if (matches && matches.length === 2) {
571-
let mnemonic = matches[1];
572-
573-
actionItemElement.accessKey = mnemonic.toLocaleLowerCase();
574-
}
575-
}
576-
577562
public push(arg: IAction | IAction[], options: IActionOptions = {}): void {
578563

579564
const actions: IAction[] = !Array.isArray(arg) ? [arg] : arg;
@@ -591,10 +576,6 @@ export class ActionBar implements IActionRunner {
591576
e.stopPropagation();
592577
});
593578

594-
if (options.isMenu) {
595-
this._addMnemonic(action, actionItemElement);
596-
}
597-
598579
let item: IActionItem = null;
599580

600581
if (this.options.actionItemProvider) {

src/vs/base/browser/ui/menu/menu.css

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
.monaco-menu .monaco-action-bar.vertical {
77
margin-left: 0;
8+
overflow: visible;
89
}
910

1011
.monaco-menu .monaco-action-bar.vertical .actions-container {
@@ -47,7 +48,8 @@
4748
background: none;
4849
}
4950

50-
.monaco-menu .monaco-action-bar.vertical .keybinding {
51+
.monaco-menu .monaco-action-bar.vertical .keybinding,
52+
.monaco-menu .monaco-action-bar.vertical .submenu-indicator {
5153
display: inline-block;
5254
-ms-flex: 2 1 auto;
5355
flex: 2 1 auto;
@@ -57,7 +59,12 @@
5759
text-align: right;
5860
}
5961

60-
.monaco-menu .monaco-action-bar.vertical .action-item.disabled .keybinding {
62+
.monaco-menu .monaco-action-bar.vertical .submenu-indicator {
63+
padding: 0.8em .5em;
64+
}
65+
66+
.monaco-menu .monaco-action-bar.vertical .action-item.disabled .keybinding,
67+
.monaco-menu .monaco-action-bar.vertical .action-item.disabled .submenu-indicator {
6168
opacity: 0.4;
6269
}
6370

src/vs/base/browser/ui/menu/menu.ts

Lines changed: 176 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
import 'vs/css!./menu';
99
import { IDisposable } from 'vs/base/common/lifecycle';
10-
import { IActionRunner, IAction } from 'vs/base/common/actions';
11-
import { ActionBar, IActionItemProvider, ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar';
12-
import { ResolvedKeybinding } from 'vs/base/common/keyCodes';
10+
import { IActionRunner, IAction, Action } from 'vs/base/common/actions';
11+
import { ActionBar, IActionItemProvider, ActionsOrientation, Separator, ActionItem, IActionItemOptions } from 'vs/base/browser/ui/actionbar/actionbar';
12+
import { ResolvedKeybinding, KeyCode } from 'vs/base/common/keyCodes';
1313
import { Event } from 'vs/base/common/event';
14-
import { addClass } from 'vs/base/browser/dom';
14+
import { addClass, EventType, EventHelper, EventLike } from 'vs/base/browser/dom';
15+
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
16+
import { $ } from 'vs/base/browser/builder';
1517

1618
export interface IMenuOptions {
1719
context?: any;
@@ -21,6 +23,18 @@ export interface IMenuOptions {
2123
ariaLabel?: string;
2224
}
2325

26+
27+
export class SubmenuAction extends Action {
28+
constructor(label: string, public entries: (SubmenuAction | IAction)[], cssClass?: string) {
29+
super(!!cssClass ? cssClass : 'submenu', label, '', true);
30+
}
31+
}
32+
33+
interface ISubMenuData {
34+
parent: Menu;
35+
submenu?: Menu;
36+
}
37+
2438
export class Menu {
2539

2640
private actionBar: ActionBar;
@@ -35,9 +49,31 @@ export class Menu {
3549
menuContainer.setAttribute('role', 'presentation');
3650
container.appendChild(menuContainer);
3751

52+
let parentData: ISubMenuData = {
53+
parent: this
54+
};
55+
56+
const getActionItem = (action: IAction) => {
57+
if (action instanceof Separator) {
58+
return new ActionItem(options.context, action, { icon: true });
59+
} else if (action instanceof SubmenuAction) {
60+
return new SubmenuActionItem(action, action.entries, parentData, options);
61+
} else {
62+
const menuItemOptions: IActionItemOptions = {};
63+
if (options.getKeyBinding) {
64+
const keybinding = options.getKeyBinding(action);
65+
if (keybinding) {
66+
menuItemOptions.keybinding = keybinding.getLabel();
67+
}
68+
}
69+
70+
return new MenuActionItem(options.context, action, menuItemOptions);
71+
}
72+
};
73+
3874
this.actionBar = new ActionBar(menuContainer, {
3975
orientation: ActionsOrientation.VERTICAL,
40-
actionItemProvider: options.actionItemProvider,
76+
actionItemProvider: options.actionItemProvider ? options.actionItemProvider : getActionItem,
4177
context: options.context,
4278
actionRunner: options.actionRunner,
4379
isMenu: true,
@@ -70,4 +106,139 @@ export class Menu {
70106
this.listener = null;
71107
}
72108
}
109+
}
110+
111+
class MenuActionItem extends ActionItem {
112+
static MNEMONIC_REGEX: RegExp = /&&(.)/g;
113+
114+
constructor(ctx: any, action: IAction, options: IActionItemOptions = {}) {
115+
options.isMenu = true;
116+
super(action, action, options);
117+
}
118+
119+
private _addMnemonic(action: IAction, actionItemElement: HTMLElement): void {
120+
let matches = MenuActionItem.MNEMONIC_REGEX.exec(action.label);
121+
if (matches && matches.length === 2) {
122+
let mnemonic = matches[1];
123+
124+
let ariaLabel = action.label.replace(MenuActionItem.MNEMONIC_REGEX, mnemonic);
125+
126+
actionItemElement.accessKey = mnemonic.toLocaleLowerCase();
127+
this.$e.attr('aria-label', ariaLabel);
128+
} else {
129+
this.$e.attr('aria-label', action.label);
130+
}
131+
}
132+
133+
public render(container: HTMLElement): void {
134+
super.render(container);
135+
136+
this._addMnemonic(this.getAction(), container);
137+
this.$e.attr('role', 'menuitem');
138+
}
139+
140+
public _updateLabel(): void {
141+
if (this.options.label) {
142+
let label = this.getAction().label;
143+
if (label && this.options.isMenu) {
144+
label = label.replace(MenuActionItem.MNEMONIC_REGEX, '$1\u0332');
145+
}
146+
this.$e.text(label);
147+
}
148+
}
149+
}
150+
151+
class SubmenuActionItem extends MenuActionItem {
152+
private mysubmenu: Menu;
153+
154+
constructor(
155+
action: IAction,
156+
private submenuActions: IAction[],
157+
private parentData: ISubMenuData,
158+
private submenuOptions?: IMenuOptions
159+
) {
160+
super(action, action, { label: true, isMenu: true });
161+
}
162+
163+
public render(container: HTMLElement): void {
164+
super.render(container);
165+
166+
this.builder = $(container);
167+
$(this.builder).addClass('monaco-submenu-item');
168+
$('span.submenu-indicator').text('\u25B6').appendTo(this.builder);
169+
this.$e.attr('role', 'menu');
170+
171+
$(this.builder).on(EventType.KEY_UP, (e) => {
172+
let event = new StandardKeyboardEvent(e as KeyboardEvent);
173+
if (event.equals(KeyCode.RightArrow)) {
174+
EventHelper.stop(e, true);
175+
176+
this.createSubmenu();
177+
}
178+
});
179+
180+
$(this.builder).on(EventType.KEY_DOWN, (e) => {
181+
let event = new StandardKeyboardEvent(e as KeyboardEvent);
182+
if (event.equals(KeyCode.RightArrow)) {
183+
EventHelper.stop(e, true);
184+
}
185+
});
186+
187+
$(this.builder).on(EventType.MOUSE_OVER, (e) => {
188+
this.cleanupExistingSubmenu(false);
189+
this.createSubmenu();
190+
});
191+
192+
193+
$(this.builder).on(EventType.MOUSE_LEAVE, (e) => {
194+
this.parentData.parent.focus();
195+
this.cleanupExistingSubmenu(true);
196+
});
197+
}
198+
199+
public onClick(e: EventLike) {
200+
// stop clicking from trying to run an action
201+
EventHelper.stop(e, true);
202+
}
203+
204+
private cleanupExistingSubmenu(force: boolean) {
205+
if (this.parentData.submenu && (force || (this.parentData.submenu !== this.mysubmenu))) {
206+
this.parentData.submenu.dispose();
207+
this.parentData.submenu = null;
208+
}
209+
}
210+
211+
private createSubmenu() {
212+
if (!this.parentData.submenu) {
213+
const submenuContainer = $(this.builder).div({ class: 'monaco-submenu menubar-menu-items-holder context-view' });
214+
215+
$(submenuContainer).style({
216+
'left': `${$(this.builder).getClientArea().width}px`
217+
});
218+
219+
$(submenuContainer).on(EventType.KEY_UP, (e) => {
220+
let event = new StandardKeyboardEvent(e as KeyboardEvent);
221+
if (event.equals(KeyCode.LeftArrow)) {
222+
EventHelper.stop(e, true);
223+
224+
this.parentData.parent.focus();
225+
this.parentData.submenu.dispose();
226+
this.parentData.submenu = null;
227+
}
228+
});
229+
230+
$(submenuContainer).on(EventType.KEY_DOWN, (e) => {
231+
let event = new StandardKeyboardEvent(e as KeyboardEvent);
232+
if (event.equals(KeyCode.LeftArrow)) {
233+
EventHelper.stop(e, true);
234+
}
235+
});
236+
237+
238+
this.parentData.submenu = new Menu(submenuContainer.getHTMLElement(), this.submenuActions, this.submenuOptions);
239+
this.parentData.submenu.focus();
240+
241+
this.mysubmenu = this.parentData.submenu;
242+
}
243+
}
73244
}

src/vs/platform/actions/browser/menuItemActionItem.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { localize } from 'vs/nls';
99
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
10-
import { IMenu, MenuItemAction, IMenuActionOptions, ICommandAction } from 'vs/platform/actions/common/actions';
10+
import { IMenu, MenuItemAction, IMenuActionOptions, ICommandAction, SubmenuItemAction } from 'vs/platform/actions/common/actions';
1111
import { IAction } from 'vs/base/common/actions';
1212
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
1313
import { ActionItem, Separator } from 'vs/base/browser/ui/actionbar/actionbar';
@@ -91,11 +91,11 @@ export function fillInActionBarActions(menu: IMenu, options: IMenuActionOptions,
9191
fillInActions(groups, target, false, isPrimaryGroup);
9292
}
9393

94-
function fillInActions(groups: [string, MenuItemAction[]][], target: IAction[] | { primary: IAction[]; secondary: IAction[]; }, getAlternativeActions, isPrimaryGroup: (group: string) => boolean = group => group === 'navigation'): void {
94+
function fillInActions(groups: [string, (MenuItemAction | SubmenuItemAction)[]][], target: IAction[] | { primary: IAction[]; secondary: IAction[]; }, getAlternativeActions, isPrimaryGroup: (group: string) => boolean = group => group === 'navigation'): void {
9595
for (let tuple of groups) {
9696
let [group, actions] = tuple;
9797
if (getAlternativeActions) {
98-
actions = actions.map(a => !!a.alt ? a.alt : a);
98+
actions = actions.map(a => (a instanceof MenuItemAction) && !!a.alt ? a.alt : a);
9999
}
100100

101101
if (isPrimaryGroup(group)) {

0 commit comments

Comments
 (0)