@lumino/widgets
Version:
Lumino Widgets
1,279 lines (1,127 loc) • 35.5 kB
text/typescript
// 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 { ElementExt } from '@lumino/domutils';
import { getKeyboardLayout } from '@lumino/keyboard';
import { Message, MessageLoop } from '@lumino/messaging';
import { CommandRegistry } from '@lumino/commands';
import {
ElementARIAAttrs,
ElementDataset,
h,
VirtualDOM,
VirtualElement
} from '@lumino/virtualdom';
import { Menu } from './menu';
import { Title } from './title';
import { Widget } from './widget';
/**
* A widget which displays menus as a canonical menu bar.
*
* #### Notes
* See also the related [example](../../examples/menubar/index.html) and
* its [source](https://github.com/jupyterlab/lumino/tree/main/examples/example-menubar).
*/
export class MenuBar extends Widget {
/**
* Construct a new menu bar.
*
* @param options - The options for initializing the menu bar.
*/
constructor(options: MenuBar.IOptions = {}) {
super({ node: Private.createNode() });
this.addClass('lm-MenuBar');
this.setFlag(Widget.Flag.DisallowLayout);
this.renderer = options.renderer || MenuBar.defaultRenderer;
this._forceItemsPosition = options.forceItemsPosition || {
forceX: true,
forceY: true
};
this._overflowMenuOptions = options.overflowMenuOptions || {
isVisible: true
};
}
/**
* Dispose of the resources held by the widget.
*/
dispose(): void {
this._closeChildMenu();
this._menus.length = 0;
super.dispose();
}
/**
* The renderer used by the menu bar.
*/
readonly renderer: MenuBar.IRenderer;
/**
* The child menu of the menu bar.
*
* #### Notes
* This will be `null` if the menu bar does not have an open menu.
*/
get childMenu(): Menu | null {
return this._childMenu;
}
/**
* The overflow index of the menu bar.
*/
get overflowIndex(): number {
return this._overflowIndex;
}
/**
* The overflow menu of the menu bar.
*/
get overflowMenu(): Menu | null {
return this._overflowMenu;
}
/**
* Get the menu bar content node.
*
* #### Notes
* This is the node which holds the menu title nodes.
*
* Modifying this node directly can lead to undefined behavior.
*/
get contentNode(): HTMLUListElement {
return this.node.getElementsByClassName(
'lm-MenuBar-content'
)[0] as HTMLUListElement;
}
/**
* Get the currently active menu.
*/
get activeMenu(): Menu | null {
return this._menus[this._activeIndex] || null;
}
/**
* Set the currently active menu.
*
* #### Notes
* If the menu does not exist, the menu will be set to `null`.
*/
set activeMenu(value: Menu | null) {
this.activeIndex = value ? this._menus.indexOf(value) : -1;
}
/**
* Get the index of the currently active menu.
*
* #### Notes
* This will be `-1` if no menu is active.
*/
get activeIndex(): number {
return this._activeIndex;
}
/**
* Set the index of the currently active menu.
*
* #### Notes
* If the menu 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._menus.length) {
value = -1;
}
// An empty menu cannot be active
if (value > -1 && this._menus[value].items.length === 0) {
value = -1;
}
// Bail early if the index will not change.
if (this._activeIndex === value) {
return;
}
// Update the active index.
this._activeIndex = value;
// Schedule an update of the items.
this.update();
}
/**
* A read-only array of the menus in the menu bar.
*/
get menus(): ReadonlyArray<Menu> {
return this._menus;
}
/**
* Open the active menu and activate its first menu item.
*
* #### Notes
* If there is no active menu, this is a no-op.
*/
openActiveMenu(): void {
// Bail early if there is no active item.
if (this._activeIndex === -1) {
return;
}
// Open the child menu.
this._openChildMenu();
// Activate the first item in the child menu.
if (this._childMenu) {
this._childMenu.activeIndex = -1;
this._childMenu.activateNextItem();
}
}
/**
* Add a menu to the end of the menu bar.
*
* @param menu - The menu to add to the menu bar.
*
* #### Notes
* If the menu is already added to the menu bar, it will be moved.
*/
addMenu(menu: Menu, update: boolean = true): void {
this.insertMenu(this._menus.length, menu, update);
}
/**
* Insert a menu into the menu bar at the specified index.
*
* @param index - The index at which to insert the menu.
*
* @param menu - The menu to insert into the menu bar.
*
* #### Notes
* The index will be clamped to the bounds of the menus.
*
* If the menu is already added to the menu bar, it will be moved.
*/
insertMenu(index: number, menu: Menu, update: boolean = true): void {
// Close the child menu before making changes.
this._closeChildMenu();
// Look up the index of the menu.
let i = this._menus.indexOf(menu);
// Clamp the insert index to the array bounds.
let j = Math.max(0, Math.min(index, this._menus.length));
// If the menu is not in the array, insert it.
if (i === -1) {
// Insert the menu into the array.
ArrayExt.insert(this._menus, j, menu);
// Add the styling class to the menu.
menu.addClass('lm-MenuBar-menu');
// Connect to the menu signals.
menu.aboutToClose.connect(this._onMenuAboutToClose, this);
menu.menuRequested.connect(this._onMenuMenuRequested, this);
menu.title.changed.connect(this._onTitleChanged, this);
// Schedule an update of the items.
if (update) {
this.update();
}
// There is nothing more to do.
return;
}
// Otherwise, the menu exists in the array and should be moved.
// Adjust the index if the location is at the end of the array.
if (j === this._menus.length) {
j--;
}
// Bail if there is no effective move.
if (i === j) {
return;
}
// Move the menu to the new locations.
ArrayExt.move(this._menus, i, j);
// Schedule an update of the items.
if (update) {
this.update();
}
}
/**
* Remove a menu from the menu bar.
*
* @param menu - The menu to remove from the menu bar.
*
* #### Notes
* This is a no-op if the menu is not in the menu bar.
*/
removeMenu(menu: Menu, update: boolean = true): void {
this.removeMenuAt(this._menus.indexOf(menu), update);
}
/**
* Remove the menu at a given index from the menu bar.
*
* @param index - The index of the menu to remove.
*
* #### Notes
* This is a no-op if the index is out of range.
*/
removeMenuAt(index: number, update: boolean = true): void {
// Close the child menu before making changes.
this._closeChildMenu();
// Remove the menu from the array.
let menu = ArrayExt.removeAt(this._menus, index);
// Bail if the index is out of range.
if (!menu) {
return;
}
// Disconnect from the menu signals.
menu.aboutToClose.disconnect(this._onMenuAboutToClose, this);
menu.menuRequested.disconnect(this._onMenuMenuRequested, this);
menu.title.changed.disconnect(this._onTitleChanged, this);
// Remove the styling class from the menu.
menu.removeClass('lm-MenuBar-menu');
// Schedule an update of the items.
if (update) {
this.update();
}
}
/**
* Remove all menus from the menu bar.
*/
clearMenus(): void {
// Bail if there is nothing to remove.
if (this._menus.length === 0) {
return;
}
// Close the child menu before making changes.
this._closeChildMenu();
// Disconnect from the menu signals and remove the styling class.
for (let menu of this._menus) {
menu.aboutToClose.disconnect(this._onMenuAboutToClose, this);
menu.menuRequested.disconnect(this._onMenuMenuRequested, this);
menu.title.changed.disconnect(this._onTitleChanged, this);
menu.removeClass('lm-MenuBar-menu');
}
// Clear the menus array.
this._menus.length = 0;
// Schedule an update of the items.
this.update();
}
/**
* Handle the DOM events for the menu bar.
*
* @param event - The DOM event sent to the menu bar.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the menu bar'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 'mousedown':
this._evtMouseDown(event as MouseEvent);
break;
case 'mousemove':
this._evtMouseMove(event as MouseEvent);
break;
case 'focusout':
this._evtFocusOut(event as FocusEvent);
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('mousedown', this);
this.node.addEventListener('mousemove', this);
this.node.addEventListener('focusout', this);
this.node.addEventListener('contextmenu', this);
}
/**
* A message handler invoked on an `'after-detach'` message.
*/
protected onAfterDetach(msg: Message): void {
this.node.removeEventListener('keydown', this);
this.node.removeEventListener('mousedown', this);
this.node.removeEventListener('mousemove', this);
this.node.removeEventListener('focusout', this);
this.node.removeEventListener('contextmenu', this);
this._closeChildMenu();
}
/**
* A message handler invoked on an `'activate-request'` message.
*/
protected onActivateRequest(msg: Message): void {
if (this.isAttached) {
this._focusItemAt(0);
}
}
/**
* A message handler invoked on a `'resize'` message.
*/
protected onResize(msg: Widget.ResizeMessage): void {
this.update();
super.onResize(msg);
}
/**
* A message handler invoked on an `'update-request'` message.
*/
protected onUpdateRequest(msg: Message): void {
let menus = this._menus;
let renderer = this.renderer;
let activeIndex = this._activeIndex;
let tabFocusIndex =
this._tabFocusIndex >= 0 && this._tabFocusIndex < menus.length
? this._tabFocusIndex
: 0;
let length = this._overflowIndex > -1 ? this._overflowIndex : menus.length;
let totalMenuSize = 0;
let isVisible = false;
// Check that the overflow menu doesn't count
length = this._overflowMenu !== null ? length - 1 : length;
let content = new Array<VirtualElement>(length);
// Render visible menus
for (let i = 0; i < length; ++i) {
content[i] = renderer.renderItem({
title: menus[i].title,
active: i === activeIndex,
tabbable: i === tabFocusIndex,
disabled: menus[i].items.length === 0,
onfocus: () => {
this._tabFocusIndex = i;
this.activeIndex = i;
}
});
// Calculate size of current menu
totalMenuSize += this._menuItemSizes[i];
// Check if overflow menu is already rendered
if (menus[i].title.label === this._overflowMenuOptions.title) {
isVisible = true;
length--;
}
}
// Render overflow menu if needed and active
if (this._overflowMenuOptions.isVisible) {
if (this._overflowIndex > -1 && !isVisible) {
// Create overflow menu
if (this._overflowMenu === null) {
const overflowMenuTitle = this._overflowMenuOptions.title ?? '...';
this._overflowMenu = new Menu({ commands: new CommandRegistry() });
this._overflowMenu.title.label = overflowMenuTitle;
this._overflowMenu.title.mnemonic = 0;
this.addMenu(this._overflowMenu, false);
}
// Move menus to overflow menu
for (let i = menus.length - 2; i >= length; i--) {
const submenu = this.menus[i];
submenu.title.mnemonic = 0;
this._overflowMenu.insertItem(0, {
type: 'submenu',
submenu: submenu
});
this.removeMenu(submenu, false);
}
content[length] = renderer.renderItem({
title: this._overflowMenu.title,
active: length === activeIndex && menus[length].items.length !== 0,
tabbable: length === tabFocusIndex,
disabled: menus[length].items.length === 0,
onfocus: () => {
this._tabFocusIndex = length;
this.activeIndex = length;
}
});
length++;
} else if (this._overflowMenu !== null) {
// Remove submenus from overflow menu
let overflowMenuItems = this._overflowMenu.items;
let screenSize = this.node.offsetWidth;
let n = this._overflowMenu.items.length;
for (let i = 0; i < n; ++i) {
let index = menus.length - 1 - i;
if (screenSize - totalMenuSize > this._menuItemSizes[index]) {
let menu = overflowMenuItems[0].submenu as Menu;
this._overflowMenu.removeItemAt(0);
this.insertMenu(length, menu, false);
content[length] = renderer.renderItem({
title: menu.title,
active: false,
tabbable: length === tabFocusIndex,
disabled: menus[length].items.length === 0,
onfocus: () => {
this._tabFocusIndex = length;
this.activeIndex = length;
}
});
length++;
}
}
if (this._overflowMenu.items.length === 0) {
this.removeMenu(this._overflowMenu, false);
content.pop();
this._overflowMenu = null;
this._overflowIndex = -1;
}
}
}
VirtualDOM.render(content, this.contentNode);
this._updateOverflowIndex();
}
/**
* Calculate and update the current overflow index.
*/
private _updateOverflowIndex(): void {
if (!this._overflowMenuOptions.isVisible) {
return;
}
// Get elements visible in the main menu bar
const itemMenus = this.contentNode.childNodes;
let screenSize = this.node.offsetWidth;
let totalMenuSize = 0;
let index = -1;
let n = itemMenus.length;
if (this._menuItemSizes.length == 0) {
// Check if it is the first resize and get info about menu items sizes
for (let i = 0; i < n; i++) {
let item = itemMenus[i] as HTMLLIElement;
// Add sizes to array
totalMenuSize += item.offsetWidth;
this._menuItemSizes.push(item.offsetWidth);
if (totalMenuSize > screenSize && index === -1) {
index = i;
}
}
} else {
// Calculate current menu size
for (let i = 0; i < this._menuItemSizes.length; i++) {
totalMenuSize += this._menuItemSizes[i];
if (totalMenuSize > screenSize) {
index = i;
break;
}
}
}
this._overflowIndex = index;
}
/**
* Handle the `'keydown'` event for the menu bar.
*
* #### Notes
* All keys are trapped except the tab key that is ignored.
*/
private _evtKeyDown(event: KeyboardEvent): void {
// Fetch the key code for the event.
let kc = event.keyCode;
// Reset the active index on tab, but do not trap the tab key.
if (kc === 9) {
this.activeIndex = -1;
return;
}
// A menu bar handles all other keydown events.
event.preventDefault();
event.stopPropagation();
// Enter, Space, Up Arrow, Down Arrow
if (kc === 13 || kc === 32 || kc === 38 || kc === 40) {
// The active index may have changed (for example, user hovers over an
// item with the mouse), so be sure to use the focus index.
this.activeIndex = this._tabFocusIndex;
if (this.activeIndex !== this._tabFocusIndex) {
// Bail if the setter refused to set activeIndex to tabFocusIndex
// because it means that the item at tabFocusIndex cannot be opened (for
// example, it has an empty menu)
return;
}
this.openActiveMenu();
return;
}
// Escape
if (kc === 27) {
this._closeChildMenu();
this._focusItemAt(this.activeIndex);
return;
}
// Left or Right Arrow
if (kc === 37 || kc === 39) {
let direction = kc === 37 ? -1 : 1;
let start = this._tabFocusIndex + direction;
let n = this._menus.length;
for (let i = 0; i < n; i++) {
let index = (n + start + direction * i) % n;
if (this._menus[index].items.length) {
this._focusItemAt(index);
return;
}
}
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._menus, key, start);
// Handle the requested mnemonic based on the search results.
// If exactly one mnemonic is matched, that menu is opened.
// 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.openActiveMenu();
} else if (result.index !== -1) {
this.activeIndex = result.index;
this._focusItemAt(this.activeIndex);
} else if (result.auto !== -1) {
this.activeIndex = result.auto;
this._focusItemAt(this.activeIndex);
}
}
/**
* Handle the `'mousedown'` event for the menu bar.
*/
private _evtMouseDown(event: MouseEvent): void {
// Bail if the mouse press was not on the menu bar. This can occur
// when the document listener is installed for an active menu bar.
if (!ElementExt.hitTest(this.node, event.clientX, event.clientY)) {
return;
}
// Stop the propagation of the event. Immediate propagation is
// also stopped so that an open menu does not handle the event.
event.stopPropagation();
event.stopImmediatePropagation();
// Check if the mouse is over one of the menu items.
let index = ArrayExt.findFirstIndex(this.contentNode.children, node => {
return ElementExt.hitTest(node, event.clientX, event.clientY);
});
// If the press was not on an item, close the child menu.
if (index === -1) {
this._closeChildMenu();
return;
}
// If the press was not the left mouse button, do nothing further.
if (event.button !== 0) {
return;
}
// Otherwise, toggle the open state of the child menu.
if (this._childMenu) {
this._closeChildMenu();
this.activeIndex = index;
} else {
// If we don't call preventDefault() here, then the item in the menu
// bar will take focus over the menu that is being opened.
event.preventDefault();
const position = this._positionForMenu(index);
Menu.saveWindowData();
// Begin DOM modifications.
this.activeIndex = index;
this._openChildMenu(position);
}
}
/**
* Handle the `'mousemove'` event for the menu bar.
*/
private _evtMouseMove(event: MouseEvent): void {
// Check if the mouse is over one of the menu items.
let index = ArrayExt.findFirstIndex(this.contentNode.children, node => {
return ElementExt.hitTest(node, event.clientX, event.clientY);
});
// Bail early if the active index will not change.
if (index === this._activeIndex) {
return;
}
// Bail early if a child menu is open and the mouse is not over
// an item. This allows the child menu to be kept open when the
// mouse is over the empty part of the menu bar.
if (index === -1 && this._childMenu) {
return;
}
// Get position for the new menu >before< updating active index.
const position =
index >= 0 && this._childMenu ? this._positionForMenu(index) : null;
// Before any modification, update window data.
Menu.saveWindowData();
// Begin DOM modifications.
// Update the active index to the hovered item.
this.activeIndex = index;
// Open the new menu if a menu is already open.
if (position) {
this._openChildMenu(position);
}
}
/**
* Find initial position for the menu based on menubar item position.
*
* NOTE: this should be called before updating active index to avoid
* an additional layout and style invalidation as changing active
* index modifies DOM.
*/
private _positionForMenu(index: number): Private.IPosition {
let itemNode = this.contentNode.children[index];
let { left, bottom } = (itemNode as HTMLElement).getBoundingClientRect();
return {
top: bottom,
left
};
}
/**
* Handle the `'focusout'` event for the menu bar.
*/
private _evtFocusOut(event: FocusEvent): void {
// Reset the active index if there is no open menu and the menubar is losing focus.
if (!this._childMenu && !this.node.contains(event.relatedTarget as Node)) {
this.activeIndex = -1;
}
}
/**
* Focus an item in the menu bar.
*
* #### Notes
* Does not open the associated menu.
*/
private _focusItemAt(index: number): void {
const itemNode = this.contentNode.childNodes[index] as HTMLElement | void;
if (itemNode) {
itemNode.focus();
}
}
/**
* Open the child menu at the active index immediately.
*
* If a different child menu is already open, it will be closed,
* even if there is no active menu.
*/
private _openChildMenu(options: { left?: number; top?: number } = {}): void {
// If there is no active menu, close the current menu.
let newMenu = this.activeMenu;
if (!newMenu) {
this._closeChildMenu();
return;
}
// Bail if there is no effective menu change.
let oldMenu = this._childMenu;
if (oldMenu === newMenu) {
return;
}
// Swap the internal menu reference.
this._childMenu = newMenu;
// Close the current menu, or setup for the new menu.
if (oldMenu) {
oldMenu.close();
} else {
document.addEventListener('mousedown', this, true);
}
// Update the tab focus index and ensure the menu bar is updated.
this._tabFocusIndex = this.activeIndex;
MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
// Get the positioning data for the new menu.
let { left, top } = options;
if (typeof left === 'undefined' || typeof top === 'undefined') {
({ left, top } = this._positionForMenu(this._activeIndex));
}
// Begin DOM modifications
if (!oldMenu) {
// Continue setup for new menu
this.addClass('lm-mod-active');
}
// Open the new menu at the computed location.
if (newMenu.items.length > 0) {
newMenu.open(left, top, this._forceItemsPosition);
}
}
/**
* Close the child menu immediately.
*
* This is a no-op if a child menu is not open.
*/
private _closeChildMenu(): void {
// Bail if no child menu is open.
if (!this._childMenu) {
return;
}
// Remove the active class from the menu bar.
this.removeClass('lm-mod-active');
// Remove the document listeners.
document.removeEventListener('mousedown', this, true);
// Clear the internal menu reference.
let menu = this._childMenu;
this._childMenu = null;
// Close the menu.
menu.close();
// Reset the active index.
this.activeIndex = -1;
}
/**
* Handle the `aboutToClose` signal of a menu.
*/
private _onMenuAboutToClose(sender: Menu): void {
// Bail if the sender is not the child menu.
if (sender !== this._childMenu) {
return;
}
// Remove the active class from the menu bar.
this.removeClass('lm-mod-active');
// Remove the document listeners.
document.removeEventListener('mousedown', this, true);
// Clear the internal menu reference.
this._childMenu = null;
// Reset the active index.
this.activeIndex = -1;
}
/**
* Handle the `menuRequested` signal of a child menu.
*/
private _onMenuMenuRequested(sender: Menu, args: 'next' | 'previous'): void {
// Bail if the sender is not the child menu.
if (sender !== this._childMenu) {
return;
}
// Look up the active index and menu count.
let i = this._activeIndex;
let n = this._menus.length;
// Active the next requested index.
switch (args) {
case 'next':
this.activeIndex = i === n - 1 ? 0 : i + 1;
break;
case 'previous':
this.activeIndex = i === 0 ? n - 1 : i - 1;
break;
}
// Open the active menu.
this.openActiveMenu();
}
/**
* Handle the `changed` signal of a title object.
*/
private _onTitleChanged(): void {
this.update();
}
// Track the index of the item that is currently focused or hovered. -1 means nothing focused or hovered.
private _activeIndex = -1;
// Track which item can be focused using the TAB key. Unlike _activeIndex will
// always point to a menuitem. Whenever you update this value, it's important
// to follow it with an "update-request" message so that the `tabindex`
// attribute on each menubar item gets properly updated.
private _tabFocusIndex = 0;
private _forceItemsPosition: Menu.IOpenOptions;
private _overflowMenuOptions: IOverflowMenuOptions;
private _menus: Menu[] = [];
private _childMenu: Menu | null = null;
private _overflowMenu: Menu | null = null;
private _menuItemSizes: number[] = [];
private _overflowIndex: number = -1;
}
/**
* The namespace for the `MenuBar` class statics.
*/
export namespace MenuBar {
/**
* An options object for creating a menu bar.
*/
export interface IOptions {
/**
* A custom renderer for creating menu bar content.
*
* The default is a shared renderer instance.
*/
renderer?: IRenderer;
/**
* Whether to force the position of the menu. The MenuBar forces the
* coordinates of its menus by default. With this option you can disable it.
*
* Setting to `false` will enable the logic which repositions the
* coordinates of the menu if it will not fit entirely on screen.
*
* The default is `true`.
*/
forceItemsPosition?: Menu.IOpenOptions;
/**
* Whether to add a overflow menu if there's overflow.
*
* Setting to `true` will enable the logic that creates an overflow menu
* to show the menu items that don't fit entirely on the screen.
*
* The default is `true`.
*/
overflowMenuOptions?: IOverflowMenuOptions;
}
/**
* An object which holds the data to render a menu bar item.
*/
export interface IRenderData {
/**
* The title to be rendered.
*/
readonly title: Title<Widget>;
/**
* Whether the item is the active item.
*/
readonly active: boolean;
/**
* Whether the user can tab to the item.
*/
readonly tabbable: boolean;
/**
* Whether the item is disabled.
*
* #### Notes
* A disabled item cannot be active.
* A disabled item cannot be focussed.
*/
readonly disabled?: boolean;
readonly onfocus?: (event: FocusEvent) => void;
}
/**
* A renderer for use with a menu bar.
*/
export interface IRenderer {
/**
* Render the virtual element for a menu bar 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 bar 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,
...(data.disabled ? {} : { tabindex: data.tabbable ? '0' : '-1' }),
onfocus: data.onfocus,
...aria
},
this.renderIcon(data),
this.renderLabel(data)
);
}
/**
* Render the icon element for a menu bar 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 data.title.icon is undefined, it will be ignored.
return h.div({ className }, data.title.icon!, data.title.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-MenuBar-itemLabel' }, content);
}
/**
* Create the class name for the menu bar item.
*
* @param data - The data to use for the class name.
*
* @returns The full class name for the menu item.
*/
createItemClass(data: IRenderData): string {
let name = 'lm-MenuBar-item';
if (data.title.className) {
name += ` ${data.title.className}`;
}
if (data.active && !data.disabled) {
name += ' lm-mod-active';
}
return name;
}
/**
* Create the dataset for a menu bar item.
*
* @param data - The data to use for the item.
*
* @returns The dataset for the menu bar item.
*/
createItemDataset(data: IRenderData): ElementDataset {
return data.title.dataset;
}
/**
* Create the aria attributes for menu bar item.
*
* @param data - The data to use for the aria attributes.
*
* @returns The aria attributes object for the item.
*/
createItemARIA(data: IRenderData): ElementARIAAttrs {
return {
role: 'menuitem',
'aria-haspopup': 'true',
'aria-disabled': data.disabled ? 'true' : 'false'
};
}
/**
* Create the class name for the menu bar 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-MenuBar-itemIcon';
let extra = data.title.iconClass;
return extra ? `${name} ${extra}` : name;
}
/**
* 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.title;
// 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-MenuBar-itemMnemonic' }, char);
// Return the content parts.
return [prefix, span, suffix];
}
}
/**
* The default `Renderer` instance.
*/
export const defaultRenderer = new Renderer();
}
/**
* Options for overflow menu.
*/
export interface IOverflowMenuOptions {
/**
* Determines if a overflow menu appears when the menu items overflow.
*
* Defaults to `true`.
*/
isVisible: boolean;
/**
* Determines the title of the overflow menu.
*
* Default: `...`.
*/
title?: string;
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* Create the DOM node for a menu bar.
*/
export function createNode(): HTMLDivElement {
let node = document.createElement('div');
let content = document.createElement('ul');
content.className = 'lm-MenuBar-content';
node.appendChild(content);
content.setAttribute('role', 'menubar');
return node;
}
/**
* Position for the menu relative to top-left screen corner.
*/
export interface IPosition {
/**
* Pixels right from screen origin.
*/
left: number;
/**
* Pixels down from screen origin.
*/
top: number;
}
/**
* 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(
menus: ReadonlyArray<Menu>,
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 = menus.length; i < n; ++i) {
// Compute the wrapped index.
let k = (i + start) % n;
// Look up the menu title.
let title = menus[k].title;
// Ignore titles with an empty label.
if (title.label.length === 0) {
continue;
}
// Look up the mnemonic index for the label.
let mn = title.mnemonic;
// Handle a valid mnemonic index.
if (mn >= 0 && mn < title.label.length) {
if (title.label[mn].toUpperCase() === upperKey) {
if (index === -1) {
index = k;
} else {
multiple = true;
}
}
continue;
}
// Finally, handle the auto index if possible.
if (auto === -1 && title.label[0].toUpperCase() === upperKey) {
auto = k;
}
}
// Return the search results.
return { index, multiple, auto };
}
}