diff --git a/examples/example-menubar/index.html b/examples/example-menubar/index.html
new file mode 100644
index 000000000..37608f290
--- /dev/null
+++ b/examples/example-menubar/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ MenuBar Example
+
+
+
+
diff --git a/examples/example-menubar/package.json b/examples/example-menubar/package.json
new file mode 100644
index 000000000..03ab6fed3
--- /dev/null
+++ b/examples/example-menubar/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@lumino/example-menubar",
+ "version": "0.1.0-alpha.6",
+ "private": true,
+ "scripts": {
+ "build": "tsc && rollup -c",
+ "clean": "rimraf build"
+ },
+ "dependencies": {
+ "@lumino/default-theme": "^1.0.0-alpha.6",
+ "@lumino/messaging": "^2.0.0-alpha.6",
+ "@lumino/widgets": "^2.0.0-alpha.6"
+ },
+ "devDependencies": {
+ "@rollup/plugin-node-resolve": "^13.3.0",
+ "rimraf": "^3.0.2",
+ "rollup": "^2.77.3",
+ "rollup-plugin-styles": "^4.0.0",
+ "typescript": "~4.7.3"
+ }
+}
diff --git a/examples/example-menubar/rollup.config.js b/examples/example-menubar/rollup.config.js
new file mode 100644
index 000000000..11c401c1f
--- /dev/null
+++ b/examples/example-menubar/rollup.config.js
@@ -0,0 +1,8 @@
+/*
+ * Copyright (c) Jupyter Development Team.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import { createRollupConfig } from '../../rollup.examples.config';
+const rollupConfig = createRollupConfig();
+export default rollupConfig;
diff --git a/examples/example-menubar/src/index.ts b/examples/example-menubar/src/index.ts
new file mode 100644
index 000000000..6df657ea8
--- /dev/null
+++ b/examples/example-menubar/src/index.ts
@@ -0,0 +1,121 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { CommandRegistry } from '@lumino/commands';
+import { Menu, MenuBar, PanelLayout, Widget } from '@lumino/widgets';
+
+import '../style/index.css';
+
+/**
+ * Wrapper widget containing the example application.
+ */
+class Application extends Widget {
+ constructor() {
+ super({ tag: 'main' });
+ }
+}
+
+/**
+ * Skip link to jump to the main content.
+ */
+class SkipLink extends Widget {
+ /**
+ * Create a HTMLElement that statically links to "#content".
+ */
+ static createNode(): HTMLElement {
+ const node = document.createElement('a');
+ node.setAttribute('href', '#content');
+ node.innerHTML = 'Skip to the main content';
+ node.classList.add('lm-example-skip-link');
+ return node;
+ }
+
+ constructor() {
+ super({ node: SkipLink.createNode() });
+ }
+}
+
+/**
+ * A Widget containing some content to provide context example.
+ */
+class Article extends Widget {
+ /**
+ * Create the content structure.
+ */
+ static createNode(): HTMLElement {
+ const node = document.createElement('div');
+ node.setAttribute('id', 'content');
+ node.setAttribute('tabindex', '-1');
+ const h1 = document.createElement('h1');
+ h1.innerHTML = 'MenuBar Example';
+ node.appendChild(h1);
+ const button = document.createElement('button');
+ button.innerHTML = 'A button you can tab to out of the menubar';
+ node.appendChild(button);
+ return node;
+ }
+
+ constructor() {
+ super({ node: Article.createNode() });
+ }
+}
+
+/**
+ * Helper Function to add menu items.
+ */
+function addMenuItem(
+ commands: CommandRegistry,
+ menu: Menu,
+ command: string,
+ label: string,
+ log: string
+): void {
+ commands.addCommand(command, {
+ label: label,
+ execute: () => {
+ console.log(log);
+ }
+ });
+ menu.addItem({
+ type: 'command',
+ command: command
+ });
+}
+
+/**
+ * Create the MenuBar example application.
+ */
+function main(): void {
+ const app = new Application();
+ const appLayout = new PanelLayout();
+ app.layout = appLayout;
+
+ const skipLink = new SkipLink();
+
+ const menubar = new MenuBar();
+ const commands = new CommandRegistry();
+
+ const fileMenu = new Menu({ commands: commands });
+ fileMenu.title.label = 'File';
+ addMenuItem(commands, fileMenu, 'new', 'New', 'File > New');
+ addMenuItem(commands, fileMenu, 'open', 'Open', 'File > Open');
+ addMenuItem(commands, fileMenu, 'save', 'Save', 'File > Save');
+ menubar.addMenu(fileMenu);
+
+ const editMenu = new Menu({ commands: commands });
+ editMenu.title.label = 'Edit';
+ addMenuItem(commands, editMenu, 'cut', 'Cut', 'Edit > Cut');
+ addMenuItem(commands, editMenu, 'copy', 'Copy', 'Edit > Copy');
+ addMenuItem(commands, editMenu, 'paste', 'Paste', 'Edit > Paste');
+ menubar.addMenu(editMenu);
+
+ const article = new Article();
+
+ appLayout.addWidget(skipLink);
+ appLayout.addWidget(menubar);
+ appLayout.addWidget(article);
+
+ Widget.attach(app, document.body);
+}
+
+window.onload = main;
diff --git a/examples/example-menubar/style/content.css b/examples/example-menubar/style/content.css
new file mode 100644
index 000000000..6f103a529
--- /dev/null
+++ b/examples/example-menubar/style/content.css
@@ -0,0 +1,30 @@
+/*
+ Copyright (c) Jupyter Development Team.
+ Distributed under the terms of the Modified BSD License.
+*/
+
+.lm-example-skip-link {
+ position: absolute;
+ left: -1000px;
+ top: -1000px;
+}
+
+.lm-example-skip-link:focus {
+ position: fixed;
+ left: 50%;
+ top: 0;
+ z-index: 10;
+ padding: 0.5rem 1rem;
+ box-shadow: 0 0 5px #000;
+ border-bottom-left-radius: 0.2rem;
+ border-bottom-right-radius: 0.2rem;
+ background: #fff;
+}
+
+textarea {
+ display: block;
+}
+
+#content {
+ padding: 0 1rem;
+}
diff --git a/examples/example-menubar/style/index.css b/examples/example-menubar/style/index.css
new file mode 100644
index 000000000..b95a371ce
--- /dev/null
+++ b/examples/example-menubar/style/index.css
@@ -0,0 +1,19 @@
+/*
+ Copyright (c) Jupyter Development Team.
+ Distributed under the terms of the Modified BSD License.
+*/
+@import '@lumino/default-theme/style/index.css';
+@import './content.css';
+
+body {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
diff --git a/examples/example-menubar/tsconfig.json b/examples/example-menubar/tsconfig.json
new file mode 100644
index 000000000..b14ef2b28
--- /dev/null
+++ b/examples/example-menubar/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "declaration": false,
+ "noImplicitAny": true,
+ "noEmitOnError": true,
+ "noUnusedLocals": true,
+ "strictNullChecks": true,
+ "sourceMap": true,
+ "module": "ES6",
+ "moduleResolution": "node",
+ "target": "ES2018",
+ "outDir": "./build",
+ "lib": ["DOM", "ES2018"],
+ "types": []
+ },
+ "include": ["src/*"]
+}
diff --git a/packages/widgets/src/menubar.ts b/packages/widgets/src/menubar.ts
index 9dbecb0eb..3f36f3faa 100644
--- a/packages/widgets/src/menubar.ts
+++ b/packages/widgets/src/menubar.ts
@@ -137,6 +137,11 @@ export class MenuBar extends Widget {
// Update the active index.
this._activeIndex = value;
+ // Update the focus index.
+ if (value !== -1) {
+ this._tabFocusIndex = value;
+ }
+
// Update focus to new active index
if (
this._activeIndex >= 0 &&
@@ -390,7 +395,7 @@ export class MenuBar extends Widget {
*/
protected onActivateRequest(msg: Message): void {
if (this.isAttached) {
- this.node.focus();
+ this.activeIndex = 0;
}
}
@@ -401,6 +406,10 @@ export class MenuBar extends Widget {
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 content = new Array(menus.length);
for (let i = 0, n = menus.length; i < n; ++i) {
let title = menus[i].title;
@@ -411,6 +420,7 @@ export class MenuBar extends Widget {
content[i] = renderer.renderItem({
title,
active,
+ tabbable: i === tabFocusIndex,
onfocus: () => {
this.activeIndex = i;
}
@@ -429,8 +439,9 @@ export class MenuBar extends Widget {
// Fetch the key code for the event.
let kc = event.keyCode;
- // Do not trap the tab key.
+ // Reset the active index on tab, but do not trap the tab key.
if (kc === 9) {
+ this.activeIndex = -1;
return;
}
@@ -438,8 +449,8 @@ export class MenuBar extends Widget {
event.preventDefault();
event.stopPropagation();
- // Enter, Up Arrow, Down Arrow
- if (kc === 13 || kc === 38 || kc === 40) {
+ // Enter, Space, Up Arrow, Down Arrow
+ if (kc === 13 || kc === 32 || kc === 38 || kc === 40) {
this.openActiveMenu();
return;
}
@@ -667,7 +678,6 @@ export class MenuBar extends Widget {
if (!this._childMenu) {
return;
}
-
// Remove the active class from the menu bar.
this.removeClass('lm-mod-active');
/* */
@@ -747,7 +757,10 @@ export class MenuBar extends Widget {
this.update();
}
+ // Track the index of the item that is currently focused. -1 means nothing focused.
private _activeIndex = -1;
+ // 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 _menus: Menu[] = [];
private _childMenu: Menu | null = null;
@@ -793,6 +806,11 @@ export namespace MenuBar {
*/
readonly active: boolean;
+ /**
+ * Whether the user can tab to the item.
+ */
+ readonly tabbable: boolean;
+
readonly onfocus?: (event: FocusEvent) => void;
}
@@ -829,7 +847,13 @@ export namespace MenuBar {
let dataset = this.createItemDataset(data);
let aria = this.createItemARIA(data);
return h.li(
- { className, dataset, tabindex: '0', onfocus: data.onfocus, ...aria },
+ {
+ className,
+ dataset,
+ tabindex: data.tabbable ? '0' : '-1',
+ onfocus: data.onfocus,
+ ...aria
+ },
this.renderIcon(data),
this.renderLabel(data)
);
@@ -998,8 +1022,6 @@ namespace Private {
/* */
node.appendChild(content);
content.setAttribute('role', 'menubar');
- node.tabIndex = 0;
- content.tabIndex = 0;
return node;
}
diff --git a/packages/widgets/tests/src/menubar.spec.ts b/packages/widgets/tests/src/menubar.spec.ts
index 55c6837d5..862b81145 100644
--- a/packages/widgets/tests/src/menubar.spec.ts
+++ b/packages/widgets/tests/src/menubar.spec.ts
@@ -73,6 +73,15 @@ describe('@lumino/widgets', () => {
return bar;
}
+ /**
+ * Create a MenuBar that has no active menu item.
+ */
+ function createUnfocusedMenuBar(): MenuBar {
+ const bar = createMenuBar();
+ bar.activeIndex = -1;
+ return bar;
+ }
+
before(() => {
commands = new CommandRegistry();
let cmd = commands.addCommand(DEFAULT_CMD, {
@@ -465,6 +474,17 @@ describe('@lumino/widgets', () => {
expect(menu.isAttached).to.equal(true);
});
+ it('should open the active menu on Space', () => {
+ let menu = bar.activeMenu!;
+ bar.node.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ bubbles,
+ keyCode: 32
+ })
+ );
+ expect(menu.isAttached).to.equal(true);
+ });
+
it('should open the active menu on Up Arrow', () => {
let menu = bar.activeMenu!;
simulate(bar.node, 'keydown', { keyCode: 38 });
@@ -664,6 +684,36 @@ describe('@lumino/widgets', () => {
expect(cancelled).to.equal(true);
});
});
+
+ context('focus', () => {
+ it('should lose focus on tab key', () => {
+ let bar = createUnfocusedMenuBar();
+ bar.activeIndex = 0;
+ expect(bar.contentNode.contains(document.activeElement)).to.equal(
+ true
+ );
+ let event = new KeyboardEvent('keydown', { keyCode: 9, bubbles });
+ bar.contentNode.dispatchEvent(event);
+ expect(bar.activeIndex).to.equal(-1);
+ bar.dispose();
+ });
+
+ it('should lose focus on shift-tab key', () => {
+ let bar = createUnfocusedMenuBar();
+ bar.activeIndex = 0;
+ expect(bar.contentNode.contains(document.activeElement)).to.equal(
+ true
+ );
+ let event = new KeyboardEvent('keydown', {
+ keyCode: 9,
+ shiftKey: true,
+ bubbles
+ });
+ bar.contentNode.dispatchEvent(event);
+ expect(bar.activeIndex).to.equal(-1);
+ bar.dispose();
+ });
+ });
});
describe('#onBeforeAttach()', () => {
@@ -712,13 +762,20 @@ describe('@lumino/widgets', () => {
let bar = createMenuBar();
Widget.detach(bar);
MessageLoop.sendMessage(bar, Widget.Msg.ActivateRequest);
- expect(bar.node.contains(document.activeElement)).to.equal(false);
+ expect(bar.contentNode.contains(document.activeElement)).to.equal(
+ false
+ );
+ bar.dispose();
});
it('should focus the node if attached', () => {
- let bar = createMenuBar();
+ let bar = createUnfocusedMenuBar();
MessageLoop.sendMessage(bar, Widget.Msg.ActivateRequest);
- expect(bar.node.contains(document.activeElement)).to.equal(true);
+ expect(
+ bar.contentNode.contains(document.activeElement) &&
+ bar.contentNode !== document.activeElement
+ ).to.equal(true);
+ bar.dispose();
});
});
@@ -785,7 +842,8 @@ describe('@lumino/widgets', () => {
widget.title.closable = true;
data = {
title: widget.title,
- active: true
+ active: true,
+ tabbable: true
};
});