diff --git a/examples/example-dockpanel/src/index.ts b/examples/example-dockpanel/src/index.ts index 81579a194..c97cd2333 100644 --- a/examples/example-dockpanel/src/index.ts +++ b/examples/example-dockpanel/src/index.ts @@ -10,7 +10,7 @@ |----------------------------------------------------------------------------*/ import { CommandRegistry } from '@lumino/commands'; -import { Message } from '@lumino/messaging'; +import { Message, MessageLoop } from '@lumino/messaging'; import { BoxPanel, @@ -446,6 +446,7 @@ function main(): void { main.addWidget(dock); window.onresize = () => { + MessageLoop.postMessage(bar, new Widget.ResizeMessage(-1, -1)); main.update(); }; diff --git a/packages/widgets/src/menubar.ts b/packages/widgets/src/menubar.ts index 3aebc4e6d..5a45743cf 100644 --- a/packages/widgets/src/menubar.ts +++ b/packages/widgets/src/menubar.ts @@ -15,6 +15,8 @@ import { getKeyboardLayout } from '@lumino/keyboard'; import { Message, MessageLoop } from '@lumino/messaging'; +import { CommandRegistry } from '@lumino/commands'; + import { ElementARIAAttrs, ElementDataset, @@ -47,6 +49,10 @@ export class MenuBar extends Widget { forceX: true, forceY: true }; + this._overflowMenuOptions = options.overflowMenuOptions || { + overflowMenuVisible: true, + title: '...' + }; } /** @@ -73,6 +79,20 @@ export class MenuBar extends Widget { 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. * @@ -188,8 +208,8 @@ export class MenuBar extends Widget { * #### Notes * If the menu is already added to the menu bar, it will be moved. */ - addMenu(menu: Menu): void { - this.insertMenu(this._menus.length, menu); + addMenu(menu: Menu, update: boolean = true): void { + this.insertMenu(this._menus.length, menu, update); } /** @@ -204,7 +224,7 @@ export class MenuBar extends Widget { * * If the menu is already added to the menu bar, it will be moved. */ - insertMenu(index: number, menu: Menu): void { + insertMenu(index: number, menu: Menu, update: boolean = true): void { // Close the child menu before making changes. this._closeChildMenu(); @@ -228,7 +248,9 @@ export class MenuBar extends Widget { menu.title.changed.connect(this._onTitleChanged, this); // Schedule an update of the items. - this.update(); + if (update) { + this.update(); + } // There is nothing more to do. return; @@ -250,7 +272,9 @@ export class MenuBar extends Widget { ArrayExt.move(this._menus, i, j); // Schedule an update of the items. - this.update(); + if (update) { + this.update(); + } } /** @@ -261,8 +285,8 @@ export class MenuBar extends Widget { * #### Notes * This is a no-op if the menu is not in the menu bar. */ - removeMenu(menu: Menu): void { - this.removeMenuAt(this._menus.indexOf(menu)); + removeMenu(menu: Menu, update: boolean = true): void { + this.removeMenuAt(this._menus.indexOf(menu), update); } /** @@ -273,7 +297,7 @@ export class MenuBar extends Widget { * #### Notes * This is a no-op if the index is out of range. */ - removeMenuAt(index: number): void { + removeMenuAt(index: number, update: boolean = true): void { // Close the child menu before making changes. this._closeChildMenu(); @@ -294,7 +318,9 @@ export class MenuBar extends Widget { menu.removeClass('lm-MenuBar-menu'); // Schedule an update of the items. - this.update(); + if (update) { + this.update(); + } } /** @@ -387,6 +413,14 @@ export class MenuBar extends Widget { } } + /** + * 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. */ @@ -398,23 +432,128 @@ export class MenuBar extends Widget { this._tabFocusIndex >= 0 && this._tabFocusIndex < menus.length ? this._tabFocusIndex : 0; - let content = new Array(menus.length); - for (let i = 0, n = menus.length; i < n; ++i) { - let title = menus[i].title; - let active = i === activeIndex; - if (active && menus[i].items.length == 0) { - active = false; - } + let length = this._overflowIndex > -1 ? this._overflowIndex : menus.length; + let totalMenuSize = 0; + let overflowMenuVisible = false; + + // Check that the overflow menu doesn't count + length = this._overflowMenu !== null ? length - 1 : length; + let content = new Array(length); + + // Render visible menus + for (let i = 0; i < length; ++i) { content[i] = renderer.renderItem({ - title, - active, + title: menus[i].title, + active: i === activeIndex && menus[i].items.length !== 0, tabbable: i === tabFocusIndex, onfocus: () => { 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) { + overflowMenuVisible = true; + length--; + } + } + // Render overflow menu if needed and active + if (this._overflowMenuOptions.overflowMenuVisible) { + if (this._overflowIndex > -1 && !overflowMenuVisible) { + // Create overflow menu + if (this._overflowMenu === null) { + this._overflowMenu = new Menu({ commands: new CommandRegistry() }); + this._overflowMenu.title.label = this._overflowMenuOptions.title; + 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, + onfocus: () => { + 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, + onfocus: () => { + 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 { + // 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; } /** @@ -741,8 +880,12 @@ export class MenuBar extends Widget { // Track which item can be focused using the TAB key. Unlike _activeIndex will always point to a menuitem. 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; } /** @@ -769,6 +912,15 @@ export namespace MenuBar { * 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; } /** @@ -952,6 +1104,22 @@ export namespace MenuBar { export const defaultRenderer = new Renderer(); } +/** + * Options for overflow menu. + */ +export interface IOverflowMenuOptions { + /** + * Determines if a overflow menu appears when the menu items overflow. + */ + overflowMenuVisible: boolean; + /** + * Determines the title of the overflow menu. + * + * Default: `...`. + */ + title: string; +} + /** * The namespace for the module implementation details. */ diff --git a/packages/widgets/tests/src/menubar.spec.ts b/packages/widgets/tests/src/menubar.spec.ts index b8694b35e..69fbe56dc 100644 --- a/packages/widgets/tests/src/menubar.spec.ts +++ b/packages/widgets/tests/src/menubar.spec.ts @@ -901,6 +901,34 @@ describe('@lumino/widgets', () => { expect(child.className).to.contain('lm-MenuBar-item'); bar.dispose(); }); + + it('should render the overflow menu', () => { + let bar = createMenuBar(); + expect(bar.overflowIndex).to.equal(-1); + expect(bar.overflowMenu).to.equal(null); + bar.node.style.maxWidth = '70px'; + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + requestAnimationFrame(() => { + expect(bar.overflowMenu).to.not.equal(null); + expect(bar.overflowIndex).to.not.equal(-1); + bar.dispose(); + }); + }); + + it('should hide the overflow menu', () => { + let bar = createMenuBar(); + expect(bar.overflowIndex).to.equal(-1); + expect(bar.overflowMenu).to.equal(null); + bar.node.style.maxWidth = '70px'; + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + bar.node.style.maxWidth = '400px'; + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + requestAnimationFrame(() => { + expect(bar.overflowMenu).to.equal(null); + expect(bar.overflowIndex).to.equal(-1); + bar.dispose(); + }); + }); }); context('`menuRequested` signal', () => { diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index 43dc5fa81..faa6b85b8 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -582,6 +582,12 @@ export namespace GridLayout { export function setCellConfig(widget: Widget, value: Partial): void; } +// @public +export interface IOverflowMenuOptions { + overflowMenuVisible: boolean; + title: string; +} + // @public export abstract class Layout implements Iterable, IDisposable { abstract [Symbol.iterator](): IterableIterator; @@ -751,21 +757,24 @@ export class MenuBar extends Widget { set activeIndex(value: number); get activeMenu(): Menu | null; set activeMenu(value: Menu | null); - addMenu(menu: Menu): void; + addMenu(menu: Menu, update?: boolean): void; get childMenu(): Menu | null; clearMenus(): void; get contentNode(): HTMLUListElement; dispose(): void; handleEvent(event: Event): void; - insertMenu(index: number, menu: Menu): void; + insertMenu(index: number, menu: Menu, update?: boolean): void; get menus(): ReadonlyArray; protected onActivateRequest(msg: Message): void; protected onAfterDetach(msg: Message): void; protected onBeforeAttach(msg: Message): void; + protected onResize(msg: Widget.ResizeMessage): void; protected onUpdateRequest(msg: Message): void; openActiveMenu(): void; - removeMenu(menu: Menu): void; - removeMenuAt(index: number): void; + get overflowIndex(): number; + get overflowMenu(): Menu | null; + removeMenu(menu: Menu, update?: boolean): void; + removeMenuAt(index: number, update?: boolean): void; readonly renderer: MenuBar.IRenderer; } @@ -773,6 +782,7 @@ export class MenuBar extends Widget { export namespace MenuBar { export interface IOptions { forceItemsPosition?: Menu.IOpenOptions; + overflowMenuOptions?: IOverflowMenuOptions; renderer?: IRenderer; } export interface IRenderData {