// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { ArrayExt } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils'; import { ElementExt } from '@lumino/domutils'; import { getKeyboardLayout } from '@lumino/keyboard'; import { Message, MessageLoop } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { ARIAAttrNames, ElementARIAAttrs, ElementDataset, h, VirtualDOM, VirtualElement } from '@lumino/virtualdom'; import { Widget } from './widget'; /** * A widget which displays items as a canonical menu. */ export class Menu extends Widget { /** * Construct a new menu. * * @param options - The options for initializing the menu. */ constructor(options: Menu.IOptions) { super({ node: Private.createNode() }); this.addClass('lm-Menu'); /* */ this.addClass('p-Menu'); /* */ this.setFlag(Widget.Flag.DisallowLayout); this.commands = options.commands; this.renderer = options.renderer || Menu.defaultRenderer; } /** * Dispose of the resources held by the menu. */ dispose(): void { this.close(); this._items.length = 0; super.dispose(); } /** * A signal emitted just before the menu is closed. * * #### Notes * This signal is emitted when the menu receives a `'close-request'` * message, just before it removes itself from the DOM. * * This signal is not emitted if the menu is already detached from * the DOM when it receives the `'close-request'` message. */ get aboutToClose(): ISignal { return this._aboutToClose; } /** * A signal emitted when a new menu is requested by the user. * * #### Notes * This signal is emitted whenever the user presses the right or left * arrow keys, and a submenu cannot be opened or closed in response. * * This signal is useful when implementing menu bars in order to open * the next or previous menu in response to a user key press. * * This signal is only emitted for the root menu in a hierarchy. */ get menuRequested(): ISignal { return this._menuRequested; } /** * The command registry used by the menu. */ readonly commands: CommandRegistry; /** * The renderer used by the menu. */ readonly renderer: Menu.IRenderer; /** * The parent menu of the menu. * * #### Notes * This is `null` unless the menu is an open submenu. */ get parentMenu(): Menu | null { return this._parentMenu; } /** * The child menu of the menu. * * #### Notes * This is `null` unless the menu has an open submenu. */ get childMenu(): Menu | null { return this._childMenu; } /** * The root menu of the menu hierarchy. */ get rootMenu(): Menu { // eslint-disable-next-line @typescript-eslint/no-this-alias let menu: Menu = this; while (menu._parentMenu) { menu = menu._parentMenu; } return menu; } /** * The leaf menu of the menu hierarchy. */ get leafMenu(): Menu { // eslint-disable-next-line @typescript-eslint/no-this-alias let menu: Menu = this; while (menu._childMenu) { menu = menu._childMenu; } return menu; } /** * The menu content node. * * #### Notes * This is the node which holds the menu item nodes. * * Modifying this node directly can lead to undefined behavior. */ get contentNode(): HTMLUListElement { return this.node.getElementsByClassName( 'lm-Menu-content' )[0] as HTMLUListElement; } /** * Get the currently active menu item. */ get activeItem(): Menu.IItem | null { return this._items[this._activeIndex] || null; } /** * Set the currently active menu item. * * #### Notes * If the item cannot be activated, the item will be set to `null`. */ set activeItem(value: Menu.IItem | null) { this.activeIndex = value ? this._items.indexOf(value) : -1; } /** * Get the index of the currently active menu item. * * #### Notes * This will be `-1` if no menu item is active. */ get activeIndex(): number { return this._activeIndex; } /** * Set the index of the currently active menu item. * * #### Notes * If the item cannot be activated, the index will be set to `-1`. */ set activeIndex(value: number) { // Adjust the value for an out of range index. if (value < 0 || value >= this._items.length) { value = -1; } // Ensure the item can be activated. if (value !== -1 && !Private.canActivate(this._items[value])) { value = -1; } // Bail if the index will not change. if (this._activeIndex === value) { return; } // Update the active index. this._activeIndex = value; // Make active element in focus if ( this._activeIndex >= 0 && this.contentNode.childNodes[this._activeIndex] ) { (this.contentNode.childNodes[this._activeIndex] as HTMLElement).focus(); } // schedule an update of the items. this.update(); } /** * A read-only array of the menu items in the menu. */ get items(): ReadonlyArray { return this._items; } /** * Activate the next selectable item in the menu. * * #### Notes * If no item is selectable, the index will be set to `-1`. */ activateNextItem(): void { let n = this._items.length; let ai = this._activeIndex; let start = ai < n - 1 ? ai + 1 : 0; let stop = start === 0 ? n - 1 : start - 1; this.activeIndex = ArrayExt.findFirstIndex( this._items, Private.canActivate, start, stop ); } /** * Activate the previous selectable item in the menu. * * #### Notes * If no item is selectable, the index will be set to `-1`. */ activatePreviousItem(): void { let n = this._items.length; let ai = this._activeIndex; let start = ai <= 0 ? n - 1 : ai - 1; let stop = start === n - 1 ? 0 : start + 1; this.activeIndex = ArrayExt.findLastIndex( this._items, Private.canActivate, start, stop ); } /** * Trigger the active menu item. * * #### Notes * If the active item is a submenu, it will be opened and the first * item will be activated. * * If the active item is a command, the command will be executed. * * If the menu is not attached, this is a no-op. * * If there is no active item, this is a no-op. */ triggerActiveItem(): void { // Bail if the menu is not attached. if (!this.isAttached) { return; } // Bail if there is no active item. let item = this.activeItem; if (!item) { return; } // Cancel the pending timers. this._cancelOpenTimer(); this._cancelCloseTimer(); // If the item is a submenu, open it. if (item.type === 'submenu') { this._openChildMenu(true); return; } // Close the root menu before executing the command. this.rootMenu.close(); // Execute the command for the item. let { command, args } = item; if (this.commands.isEnabled(command, args)) { this.commands.execute(command, args); } else { console.log(`Command '${command}' is disabled.`); } } /** * Add a menu item to the end of the menu. * * @param options - The options for creating the menu item. * * @returns The menu item added to the menu. */ addItem(options: Menu.IItemOptions): Menu.IItem { return this.insertItem(this._items.length, options); } /** * Insert a menu item into the menu at the specified index. * * @param index - The index at which to insert the item. * * @param options - The options for creating the menu item. * * @returns The menu item added to the menu. * * #### Notes * The index will be clamped to the bounds of the items. */ insertItem(index: number, options: Menu.IItemOptions): Menu.IItem { // Close the menu if it's attached. if (this.isAttached) { this.close(); } // Reset the active index. this.activeIndex = -1; // Clamp the insert index to the array bounds. let i = Math.max(0, Math.min(index, this._items.length)); // Create the item for the options. let item = Private.createItem(this, options); // Insert the item into the array. ArrayExt.insert(this._items, i, item); // Schedule an update of the items. this.update(); // Return the item added to the menu. return item; } /** * Remove an item from the menu. * * @param item - The item to remove from the menu. * * #### Notes * This is a no-op if the item is not in the menu. */ removeItem(item: Menu.IItem): void { this.removeItemAt(this._items.indexOf(item)); } /** * Remove the item at a given index from the menu. * * @param index - The index of the item to remove. * * #### Notes * This is a no-op if the index is out of range. */ removeItemAt(index: number): void { // Close the menu if it's attached. if (this.isAttached) { this.close(); } // Reset the active index. this.activeIndex = -1; // Remove the item from the array. let item = ArrayExt.removeAt(this._items, index); // Bail if the index is out of range. if (!item) { return; } // Schedule an update of the items. this.update(); } /** * Remove all menu items from the menu. */ clearItems(): void { // Close the menu if it's attached. if (this.isAttached) { this.close(); } // Reset the active index. this.activeIndex = -1; // Bail if there is nothing to remove. if (this._items.length === 0) { return; } // Clear the items. this._items.length = 0; // Schedule an update of the items. this.update(); } /** * Open the menu at the specified location. * * @param x - The client X coordinate of the menu location. * * @param y - The client Y coordinate of the menu location. * * @param options - The additional options for opening the menu. * * #### Notes * The menu will be opened at the given location unless it will not * fully fit on the screen. If it will not fit, it will be adjusted * to fit naturally on the screen. * * This is a no-op if the menu is already attached to the DOM. */ open(x: number, y: number, options: Menu.IOpenOptions = {}): void { // Bail early if the menu is already attached. if (this.isAttached) { return; } // Extract the position options. let forceX = options.forceX || false; let forceY = options.forceY || false; // Open the menu as a root menu. Private.openRootMenu(this, x, y, forceX, forceY); // Activate the menu to accept keyboard input. this.activate(); } /** * Handle the DOM events for the menu. * * @param event - The DOM event sent to the menu. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the menu's DOM nodes. It should * not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._evtKeyDown(event as KeyboardEvent); break; case 'mouseup': this._evtMouseUp(event as MouseEvent); break; case 'mousemove': this._evtMouseMove(event as MouseEvent); break; case 'mouseenter': this._evtMouseEnter(event as MouseEvent); break; case 'mouseleave': this._evtMouseLeave(event as MouseEvent); break; case 'mousedown': this._evtMouseDown(event as MouseEvent); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; } } /** * A message handler invoked on a `'before-attach'` message. */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('keydown', this); this.node.addEventListener('mouseup', this); this.node.addEventListener('mousemove', this); this.node.addEventListener('mouseenter', this); this.node.addEventListener('mouseleave', this); this.node.addEventListener('contextmenu', this); document.addEventListener('mousedown', this, true); } /** * A message handler invoked on an `'after-detach'` message. */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('keydown', this); this.node.removeEventListener('mouseup', this); this.node.removeEventListener('mousemove', this); this.node.removeEventListener('mouseenter', this); this.node.removeEventListener('mouseleave', this); this.node.removeEventListener('contextmenu', this); document.removeEventListener('mousedown', this, true); } /** * A message handler invoked on an `'activate-request'` message. */ protected onActivateRequest(msg: Message): void { if (this.isAttached) { this.node.focus(); } } /** * A message handler invoked on an `'update-request'` message. */ protected onUpdateRequest(msg: Message): void { let items = this._items; let renderer = this.renderer; let activeIndex = this._activeIndex; let collapsedFlags = Private.computeCollapsed(items); let content = new Array(items.length); for (let i = 0, n = items.length; i < n; ++i) { let item = items[i]; let active = i === activeIndex; let collapsed = collapsedFlags[i]; content[i] = renderer.renderItem({ item, active, collapsed, onfocus: () => { this.activeIndex = i; } }); } VirtualDOM.render(content, this.contentNode); } /** * A message handler invoked on a `'close-request'` message. */ protected onCloseRequest(msg: Message): void { // Cancel the pending timers. this._cancelOpenTimer(); this._cancelCloseTimer(); // Reset the active index. this.activeIndex = -1; // Close any open child menu. let childMenu = this._childMenu; if (childMenu) { this._childIndex = -1; this._childMenu = null; childMenu._parentMenu = null; childMenu.close(); } // Remove this menu from its parent and activate the parent. let parentMenu = this._parentMenu; if (parentMenu) { this._parentMenu = null; parentMenu._childIndex = -1; parentMenu._childMenu = null; parentMenu.activate(); } // Emit the `aboutToClose` signal if the menu is attached. if (this.isAttached) { this._aboutToClose.emit(undefined); } // Finish closing the menu. super.onCloseRequest(msg); } /** * Handle the `'keydown'` event for the menu. * * #### Notes * This listener is attached to the menu node. */ private _evtKeyDown(event: KeyboardEvent): void { // A menu handles all keydown events. event.preventDefault(); event.stopPropagation(); // Fetch the key code for the event. let kc = event.keyCode; // Enter if (kc === 13) { this.triggerActiveItem(); return; } // Escape if (kc === 27) { this.close(); return; } // Left Arrow if (kc === 37) { if (this._parentMenu) { this.close(); } else { this._menuRequested.emit('previous'); } return; } // Up Arrow if (kc === 38) { this.activatePreviousItem(); return; } // Right Arrow if (kc === 39) { let item = this.activeItem; if (item && item.type === 'submenu') { this.triggerActiveItem(); } else { this.rootMenu._menuRequested.emit('next'); } return; } // Down Arrow if (kc === 40) { this.activateNextItem(); return; } // Get the pressed key character. let key = getKeyboardLayout().keyForKeydownEvent(event); // Bail if the key is not valid. if (!key) { return; } // Search for the next best matching mnemonic item. let start = this._activeIndex + 1; let result = Private.findMnemonic(this._items, key, start); // Handle the requested mnemonic based on the search results. // If exactly one mnemonic is matched, that item is triggered. // Otherwise, the next mnemonic is activated if available, // followed by the auto mnemonic if available. if (result.index !== -1 && !result.multiple) { this.activeIndex = result.index; this.triggerActiveItem(); } else if (result.index !== -1) { this.activeIndex = result.index; } else if (result.auto !== -1) { this.activeIndex = result.auto; } } /** * Handle the `'mouseup'` event for the menu. * * #### Notes * This listener is attached to the menu node. */ private _evtMouseUp(event: MouseEvent): void { if (event.button !== 0) { return; } event.preventDefault(); event.stopPropagation(); this.triggerActiveItem(); } /** * Handle the `'mousemove'` event for the menu. * * #### Notes * This listener is attached to the menu node. */ private _evtMouseMove(event: MouseEvent): void { // Hit test the item nodes for the item under the mouse. let index = ArrayExt.findFirstIndex(this.contentNode.children, node => { return ElementExt.hitTest(node, event.clientX, event.clientY); }); // Bail early if the mouse is already over the active index. if (index === this._activeIndex) { return; } // Update and coerce the active index. this.activeIndex = index; index = this.activeIndex; // If the index is the current child index, cancel the timers. if (index === this._childIndex) { this._cancelOpenTimer(); this._cancelCloseTimer(); return; } // If a child menu is currently open, start the close timer. if (this._childIndex !== -1) { this._startCloseTimer(); } // Cancel the open timer to give a full delay for opening. this._cancelOpenTimer(); // Bail if the active item is not a valid submenu item. let item = this.activeItem; if (!item || item.type !== 'submenu' || !item.submenu) { return; } // Start the open timer to open the active item submenu. this._startOpenTimer(); } /** * Handle the `'mouseenter'` event for the menu. * * #### Notes * This listener is attached to the menu node. */ private _evtMouseEnter(event: MouseEvent): void { // Synchronize the active ancestor items. for (let menu = this._parentMenu; menu; menu = menu._parentMenu) { menu._cancelOpenTimer(); menu._cancelCloseTimer(); menu.activeIndex = menu._childIndex; } } /** * Handle the `'mouseleave'` event for the menu. * * #### Notes * This listener is attached to the menu node. */ private _evtMouseLeave(event: MouseEvent): void { // Cancel any pending submenu opening. this._cancelOpenTimer(); // If there is no open child menu, just reset the active index. if (!this._childMenu) { this.activeIndex = -1; return; } // If the mouse is over the child menu, cancel the close timer. let { clientX, clientY } = event; if (ElementExt.hitTest(this._childMenu.node, clientX, clientY)) { this._cancelCloseTimer(); return; } // Otherwise, reset the active index and start the close timer. this.activeIndex = -1; this._startCloseTimer(); } /** * Handle the `'mousedown'` event for the menu. * * #### Notes * This listener is attached to the document node. */ private _evtMouseDown(event: MouseEvent): void { // Bail if the menu is not a root menu. if (this._parentMenu) { return; } // The mouse button which is pressed is irrelevant. If the press // is not on a menu, the entire hierarchy is closed and the event // is allowed to propagate. This allows other code to act on the // event, such as focusing the clicked element. if (Private.hitTestMenus(this, event.clientX, event.clientY)) { event.preventDefault(); event.stopPropagation(); } else { this.close(); } } /** * Open the child menu at the active index immediately. * * If a different child menu is already open, it will be closed, * even if the active item is not a valid submenu. */ private _openChildMenu(activateFirst = false): void { // If the item is not a valid submenu, close the child menu. let item = this.activeItem; if (!item || item.type !== 'submenu' || !item.submenu) { this._closeChildMenu(); return; } // Do nothing if the child menu will not change. let submenu = item.submenu; if (submenu === this._childMenu) { return; } // Ensure the current child menu is closed. this._closeChildMenu(); // Update the private child state. this._childMenu = submenu; this._childIndex = this._activeIndex; // Set the parent menu reference for the child. submenu._parentMenu = this; // Ensure the menu is updated and lookup the item node. MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest); let itemNode = this.contentNode.children[this._activeIndex]; // Open the submenu at the active node. Private.openSubmenu(submenu, itemNode as HTMLElement); // Activate the first item if desired. if (activateFirst) { submenu.activeIndex = -1; submenu.activateNextItem(); } // Activate the child menu. submenu.activate(); } /** * Close the child menu immediately. * * This is a no-op if a child menu is not open. */ private _closeChildMenu(): void { if (this._childMenu) { this._childMenu.close(); } } /** * Start the open timer, unless it is already pending. */ private _startOpenTimer(): void { if (this._openTimerID === 0) { this._openTimerID = window.setTimeout(() => { this._openTimerID = 0; this._openChildMenu(); }, Private.TIMER_DELAY); } } /** * Start the close timer, unless it is already pending. */ private _startCloseTimer(): void { if (this._closeTimerID === 0) { this._closeTimerID = window.setTimeout(() => { this._closeTimerID = 0; this._closeChildMenu(); }, Private.TIMER_DELAY); } } /** * Cancel the open timer, if the timer is pending. */ private _cancelOpenTimer(): void { if (this._openTimerID !== 0) { clearTimeout(this._openTimerID); this._openTimerID = 0; } } /** * Cancel the close timer, if the timer is pending. */ private _cancelCloseTimer(): void { if (this._closeTimerID !== 0) { clearTimeout(this._closeTimerID); this._closeTimerID = 0; } } private _childIndex = -1; private _activeIndex = -1; private _openTimerID = 0; private _closeTimerID = 0; private _items: Menu.IItem[] = []; private _childMenu: Menu | null = null; private _parentMenu: Menu | null = null; private _aboutToClose = new Signal(this); private _menuRequested = new Signal(this); } /** * The namespace for the `Menu` class statics. */ export namespace Menu { /** * An options object for creating a menu. */ export interface IOptions { /** * The command registry for use with the menu. */ commands: CommandRegistry; /** * A custom renderer for use with the menu. * * The default is a shared renderer instance. */ renderer?: IRenderer; } /** * An options object for the `open` method on a menu. */ export interface IOpenOptions { /** * Whether to force the X position of the menu. * * Setting to `true` will disable the logic which repositions the * X coordinate of the menu if it will not fit entirely on screen. * * The default is `false`. */ forceX?: boolean; /** * Whether to force the Y position of the menu. * * Setting to `true` will disable the logic which repositions the * Y coordinate of the menu if it will not fit entirely on screen. * * The default is `false`. */ forceY?: boolean; } /** * A type alias for a menu item type. */ export type ItemType = 'command' | 'submenu' | 'separator'; /** * An options object for creating a menu item. */ export interface IItemOptions { /** * The type of the menu item. * * The default value is `'command'`. */ type?: ItemType; /** * The command to execute when the item is triggered. * * The default value is an empty string. */ command?: string; /** * The arguments for the command. * * The default value is an empty object. */ args?: ReadonlyJSONObject; /** * The submenu for a `'submenu'` type item. * * The default value is `null`. */ submenu?: Menu | null; } /** * An object which represents a menu item. * * #### Notes * Item objects are created automatically by a menu. */ export interface IItem { /** * The type of the menu item. */ readonly type: ItemType; /** * The command to execute when the item is triggered. */ readonly command: string; /** * The arguments for the command. */ readonly args: ReadonlyJSONObject; /** * The submenu for a `'submenu'` type item. */ readonly submenu: Menu | null; /** * The display label for the menu item. */ readonly label: string; /** * The mnemonic index for the menu item. */ readonly mnemonic: number; /** * The icon renderer for the menu item. */ readonly icon: | VirtualElement.IRenderer | undefined /* */ | string /* */; /** * The icon class for the menu item. */ readonly iconClass: string; /** * The icon label for the menu item. */ readonly iconLabel: string; /** * The display caption for the menu item. */ readonly caption: string; /** * The extra class name for the menu item. */ readonly className: string; /** * The dataset for the menu item. */ readonly dataset: CommandRegistry.Dataset; /** * Whether the menu item is enabled. */ readonly isEnabled: boolean; /** * Whether the menu item is toggled. */ readonly isToggled: boolean; /** * Whether the menu item is visible. */ readonly isVisible: boolean; /** * The key binding for the menu item. */ readonly keyBinding: CommandRegistry.IKeyBinding | null; } /** * An object which holds the data to render a menu item. */ export interface IRenderData { /** * The item to be rendered. */ readonly item: IItem; /** * Whether the item is the active item. */ readonly active: boolean; /** * Whether the item should be collapsed. */ readonly collapsed: boolean; /** * Handler for when element is in focus. */ readonly onfocus?: () => void; } /** * A renderer for use with a menu. */ export interface IRenderer { /** * Render the virtual element for a menu item. * * @param data - The data to use for rendering the item. * * @returns A virtual element representing the item. */ renderItem(data: IRenderData): VirtualElement; } /** * The default implementation of `IRenderer`. * * #### Notes * Subclasses are free to reimplement rendering methods as needed. */ export class Renderer implements IRenderer { /** * Render the virtual element for a menu item. * * @param data - The data to use for rendering the item. * * @returns A virtual element representing the item. */ renderItem(data: IRenderData): VirtualElement { let className = this.createItemClass(data); let dataset = this.createItemDataset(data); let aria = this.createItemARIA(data); return h.li( { className, dataset, tabindex: '0', onfocus: data.onfocus, ...aria }, this.renderIcon(data), this.renderLabel(data), this.renderShortcut(data), this.renderSubmenu(data) ); } /** * Render the icon element for a menu item. * * @param data - The data to use for rendering the icon. * * @returns A virtual element representing the item icon. */ renderIcon(data: IRenderData): VirtualElement { let className = this.createIconClass(data); /* */ if (typeof data.item.icon === 'string') { return h.div({ className }, data.item.iconLabel); } /* */ // if data.item.icon is undefined, it will be ignored return h.div({ className }, data.item.icon!, data.item.iconLabel); } /** * Render the label element for a menu item. * * @param data - The data to use for rendering the label. * * @returns A virtual element representing the item label. */ renderLabel(data: IRenderData): VirtualElement { let content = this.formatLabel(data); return h.div( { className: 'lm-Menu-itemLabel' + /* */ ' p-Menu-itemLabel' /* */ }, content ); } /** * Render the shortcut element for a menu item. * * @param data - The data to use for rendering the shortcut. * * @returns A virtual element representing the item shortcut. */ renderShortcut(data: IRenderData): VirtualElement { let content = this.formatShortcut(data); return h.div( { className: 'lm-Menu-itemShortcut' + /* */ ' p-Menu-itemShortcut' /* */ }, content ); } /** * Render the submenu icon element for a menu item. * * @param data - The data to use for rendering the submenu icon. * * @returns A virtual element representing the submenu icon. */ renderSubmenu(data: IRenderData): VirtualElement { return h.div({ className: 'lm-Menu-itemSubmenuIcon' + /* */ ' p-Menu-itemSubmenuIcon' /* */ }); } /** * Create the class name for the menu item. * * @param data - The data to use for the class name. * * @returns The full class name for the menu item. */ createItemClass(data: IRenderData): string { // Setup the initial class name. let name = 'lm-Menu-item'; /* */ name += ' p-Menu-item'; /* */ // Add the boolean state classes. if (!data.item.isEnabled) { name += ' lm-mod-disabled'; /* */ name += ' p-mod-disabled'; /* */ } if (data.item.isToggled) { name += ' lm-mod-toggled'; /* */ name += ' p-mod-toggled'; /* */ } if (!data.item.isVisible) { name += ' lm-mod-hidden'; /* */ name += ' p-mod-hidden'; /* */ } if (data.active) { name += ' lm-mod-active'; /* */ name += ' p-mod-active'; /* */ } if (data.collapsed) { name += ' lm-mod-collapsed'; /* */ name += ' p-mod-collapsed'; /* */ } // Add the extra class. let extra = data.item.className; if (extra) { name += ` ${extra}`; } // Return the complete class name. return name; } /** * Create the dataset for the menu item. * * @param data - The data to use for creating the dataset. * * @returns The dataset for the menu item. */ createItemDataset(data: IRenderData): ElementDataset { let result: ElementDataset; let { type, command, dataset } = data.item; if (type === 'command') { result = { ...dataset, type, command }; } else { result = { ...dataset, type }; } return result; } /** * Create the class name for the menu item icon. * * @param data - The data to use for the class name. * * @returns The full class name for the item icon. */ createIconClass(data: IRenderData): string { let name = 'lm-Menu-itemIcon'; /* */ name += ' p-Menu-itemIcon'; /* */ let extra = data.item.iconClass; return extra ? `${name} ${extra}` : name; } /** * Create the aria attributes for menu item. * * @param data - The data to use for the aria attributes. * * @returns The aria attributes object for the item. */ createItemARIA(data: IRenderData): ElementARIAAttrs { let aria: { [T in ARIAAttrNames]?: string } = {}; switch (data.item.type) { case 'separator': aria.role = 'presentation'; break; case 'submenu': aria['aria-haspopup'] = 'true'; if (!data.item.isEnabled) { aria['aria-disabled'] = 'true'; } break; default: if (!data.item.isEnabled) { aria['aria-disabled'] = 'true'; } aria.role = 'menuitem'; } return aria; } /** * Create the render content for the label node. * * @param data - The data to use for the label content. * * @returns The content to add to the label node. */ formatLabel(data: IRenderData): h.Child { // Fetch the label text and mnemonic index. let { label, mnemonic } = data.item; // If the index is out of range, do not modify the label. if (mnemonic < 0 || mnemonic >= label.length) { return label; } // Split the label into parts. let prefix = label.slice(0, mnemonic); let suffix = label.slice(mnemonic + 1); let char = label[mnemonic]; // Wrap the mnemonic character in a span. let span = h.span( { className: 'lm-Menu-itemMnemonic' + /* */ ' p-Menu-itemMnemonic' /* */ }, char ); // Return the content parts. return [prefix, span, suffix]; } /** * Create the render content for the shortcut node. * * @param data - The data to use for the shortcut content. * * @returns The content to add to the shortcut node. */ formatShortcut(data: IRenderData): h.Child { let kb = data.item.keyBinding; return kb ? kb.keys.map(CommandRegistry.formatKeystroke).join(', ') : null; } } /** * The default `Renderer` instance. */ export const defaultRenderer = new Renderer(); } /** * The namespace for the module implementation details. */ namespace Private { /** * The ms delay for opening and closing a submenu. */ export const TIMER_DELAY = 300; /** * The horizontal pixel overlap for an open submenu. */ export const SUBMENU_OVERLAP = 3; /** * Create the DOM node for a menu. */ export function createNode(): HTMLDivElement { let node = document.createElement('div'); let content = document.createElement('ul'); content.className = 'lm-Menu-content'; /* */ content.classList.add('p-Menu-content'); /* */ node.appendChild(content); content.setAttribute('role', 'menu'); node.tabIndex = 0; return node; } /** * Test whether a menu item can be activated. */ export function canActivate(item: Menu.IItem): boolean { return item.type !== 'separator' && item.isEnabled && item.isVisible; } /** * Create a new menu item for an owner menu. */ export function createItem( owner: Menu, options: Menu.IItemOptions ): Menu.IItem { return new MenuItem(owner.commands, options); } /** * Hit test a menu hierarchy starting at the given root. */ export function hitTestMenus(menu: Menu, x: number, y: number): boolean { for (let temp: Menu | null = menu; temp; temp = temp.childMenu) { if (ElementExt.hitTest(temp.node, x, y)) { return true; } } return false; } /** * Compute which extra separator items should be collapsed. */ export function computeCollapsed( items: ReadonlyArray ): boolean[] { // Allocate the return array and fill it with `false`. let result = new Array(items.length); ArrayExt.fill(result, false); // Collapse the leading separators. let k1 = 0; let n = items.length; for (; k1 < n; ++k1) { let item = items[k1]; if (!item.isVisible) { continue; } if (item.type !== 'separator') { break; } result[k1] = true; } // Hide the trailing separators. let k2 = n - 1; for (; k2 >= 0; --k2) { let item = items[k2]; if (!item.isVisible) { continue; } if (item.type !== 'separator') { break; } result[k2] = true; } // Hide the remaining consecutive separators. let hide = false; while (++k1 < k2) { let item = items[k1]; if (!item.isVisible) { continue; } if (item.type !== 'separator') { hide = false; } else if (hide) { result[k1] = true; } else { hide = true; } } // Return the resulting flags. return result; } /** * Open a menu as a root menu at the target location. */ export function openRootMenu( menu: Menu, x: number, y: number, forceX: boolean, forceY: boolean ): void { // Ensure the menu is updated before attaching and measuring. MessageLoop.sendMessage(menu, Widget.Msg.UpdateRequest); // Get the current position and size of the main viewport. let px = window.pageXOffset; let py = window.pageYOffset; let cw = document.documentElement.clientWidth; let ch = document.documentElement.clientHeight; // Compute the maximum allowed height for the menu. let maxHeight = ch - (forceY ? y : 0); // Fetch common variables. let node = menu.node; let style = node.style; // Clear the menu geometry and prepare it for measuring. style.top = ''; style.left = ''; style.width = ''; style.height = ''; style.visibility = 'hidden'; style.maxHeight = `${maxHeight}px`; // Attach the menu to the document. Widget.attach(menu, document.body); // Measure the size of the menu. let { width, height } = node.getBoundingClientRect(); // Adjust the X position of the menu to fit on-screen. if (!forceX && x + width > px + cw) { x = px + cw - width; } // Adjust the Y position of the menu to fit on-screen. if (!forceY && y + height > py + ch) { if (y > py + ch) { y = py + ch - height; } else { y = y - height; } } // Update the position of the menu to the computed position. style.top = `${Math.max(0, y)}px`; style.left = `${Math.max(0, x)}px`; // Finally, make the menu visible on the screen. style.visibility = ''; } /** * Open a menu as a submenu using an item node for positioning. */ export function openSubmenu(submenu: Menu, itemNode: HTMLElement): void { // Ensure the menu is updated before opening. MessageLoop.sendMessage(submenu, Widget.Msg.UpdateRequest); // Get the current position and size of the main viewport. let px = window.pageXOffset; let py = window.pageYOffset; let cw = document.documentElement.clientWidth; let ch = document.documentElement.clientHeight; // Compute the maximum allowed height for the menu. let maxHeight = ch; // Fetch common variables. let node = submenu.node; let style = node.style; // Clear the menu geometry and prepare it for measuring. style.top = ''; style.left = ''; style.width = ''; style.height = ''; style.visibility = 'hidden'; style.maxHeight = `${maxHeight}px`; // Attach the menu to the document. Widget.attach(submenu, document.body); // Measure the size of the menu. let { width, height } = node.getBoundingClientRect(); // Compute the box sizing for the menu. let box = ElementExt.boxSizing(submenu.node); // Get the bounding rect for the target item node. let itemRect = itemNode.getBoundingClientRect(); // Compute the target X position. let x = itemRect.right - SUBMENU_OVERLAP; // Adjust the X position to fit on the screen. if (x + width > px + cw) { x = itemRect.left + SUBMENU_OVERLAP - width; } // Compute the target Y position. let y = itemRect.top - box.borderTop - box.paddingTop; // Adjust the Y position to fit on the screen. if (y + height > py + ch) { y = itemRect.bottom + box.borderBottom + box.paddingBottom - height; } // Update the position of the menu to the computed position. style.top = `${Math.max(0, y)}px`; style.left = `${Math.max(0, x)}px`; // Finally, make the menu visible on the screen. style.visibility = ''; } /** * The results of a mnemonic search. */ export interface IMnemonicResult { /** * The index of the first matching mnemonic item, or `-1`. */ index: number; /** * Whether multiple mnemonic items matched. */ multiple: boolean; /** * The index of the first auto matched non-mnemonic item. */ auto: number; } /** * Find the best matching mnemonic item. * * The search starts at the given index and wraps around. */ export function findMnemonic( items: ReadonlyArray, key: string, start: number ): IMnemonicResult { // Setup the result variables. let index = -1; let auto = -1; let multiple = false; // Normalize the key to upper case. let upperKey = key.toUpperCase(); // Search the items from the given start index. for (let i = 0, n = items.length; i < n; ++i) { // Compute the wrapped index. let k = (i + start) % n; // Lookup the item let item = items[k]; // Ignore items which cannot be activated. if (!canActivate(item)) { continue; } // Ignore items with an empty label. let label = item.label; if (label.length === 0) { continue; } // Lookup the mnemonic index for the label. let mn = item.mnemonic; // Handle a valid mnemonic index. if (mn >= 0 && mn < label.length) { if (label[mn].toUpperCase() === upperKey) { if (index === -1) { index = k; } else { multiple = true; } } continue; } // Finally, handle the auto index if possible. if (auto === -1 && label[0].toUpperCase() === upperKey) { auto = k; } } // Return the search results. return { index, multiple, auto }; } /** * A concrete implementation of `Menu.IItem`. */ class MenuItem implements Menu.IItem { /** * Construct a new menu item. */ constructor(commands: CommandRegistry, options: Menu.IItemOptions) { this._commands = commands; this.type = options.type || 'command'; this.command = options.command || ''; this.args = options.args || JSONExt.emptyObject; this.submenu = options.submenu || null; } /** * The type of the menu item. */ readonly type: Menu.ItemType; /** * The command to execute when the item is triggered. */ readonly command: string; /** * The arguments for the command. */ readonly args: ReadonlyJSONObject; /** * The submenu for a `'submenu'` type item. */ readonly submenu: Menu | null; /** * The display label for the menu item. */ get label(): string { if (this.type === 'command') { return this._commands.label(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.label; } return ''; } /** * The mnemonic index for the menu item. */ get mnemonic(): number { if (this.type === 'command') { return this._commands.mnemonic(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.mnemonic; } return -1; } /** * The icon renderer for the menu item. */ get icon(): | VirtualElement.IRenderer | undefined /* */ | string /* */ { if (this.type === 'command') { return this._commands.icon(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.icon; } /* */ // alias to icon class if not otherwise defined return this.iconClass; /* */ /* return undefined; */ } /** * The icon class for the menu item. */ get iconClass(): string { if (this.type === 'command') { return this._commands.iconClass(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.iconClass; } return ''; } /** * The icon label for the menu item. */ get iconLabel(): string { if (this.type === 'command') { return this._commands.iconLabel(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.iconLabel; } return ''; } /** * The display caption for the menu item. */ get caption(): string { if (this.type === 'command') { return this._commands.caption(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.caption; } return ''; } /** * The extra class name for the menu item. */ get className(): string { if (this.type === 'command') { return this._commands.className(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.className; } return ''; } /** * The dataset for the menu item. */ get dataset(): CommandRegistry.Dataset { if (this.type === 'command') { return this._commands.dataset(this.command, this.args); } if (this.type === 'submenu' && this.submenu) { return this.submenu.title.dataset; } return {}; } /** * Whether the menu item is enabled. */ get isEnabled(): boolean { if (this.type === 'command') { return this._commands.isEnabled(this.command, this.args); } if (this.type === 'submenu') { return this.submenu !== null; } return true; } /** * Whether the menu item is toggled. */ get isToggled(): boolean { if (this.type === 'command') { return this._commands.isToggled(this.command, this.args); } return false; } /** * Whether the menu item is visible. */ get isVisible(): boolean { if (this.type === 'command') { return this._commands.isVisible(this.command, this.args); } if (this.type === 'submenu') { return this.submenu !== null; } return true; } /** * The key binding for the menu item. */ get keyBinding(): CommandRegistry.IKeyBinding | null { if (this.type === 'command') { let { command, args } = this; return ( ArrayExt.findLastValue(this._commands.keyBindings, kb => { return kb.command === command && JSONExt.deepEqual(kb.args, args); }) || null ); } return null; } private _commands: CommandRegistry; } }