Browse Source

Merge pull request #8809 from ellisonbg/cp-modal

Make the command palette modal
Jason Grout 4 years ago
parent
commit
cb3595e5e8

+ 10 - 3
packages/apputils-extension/schema/palette.json

@@ -8,7 +8,14 @@
       "selector": "body"
     }
   ],
-  "additionalProperties": false,
-  "properties": {},
-  "type": "object"
+  "properties": {
+    "modal": {
+      "title": "Modal Command Palette",
+      "description": "Whether the command palette should be modal or in the left panel.",
+      "type": "boolean",
+      "default": true
+    }
+  },
+  "type": "object",
+  "additionalProperties": false
 }

+ 9 - 2
packages/apputils-extension/src/index.ts

@@ -37,6 +37,8 @@ import { Palette } from './palette';
 
 import { settingsPlugin } from './settingsplugin';
 
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
+
 import { themesPlugin, themesPaletteMenuPlugin } from './themesplugins';
 
 import { workspacesPlugin } from './workspacesplugin';
@@ -70,8 +72,13 @@ const palette: JupyterFrontEndPlugin<ICommandPalette> = {
   autoStart: true,
   requires: [ITranslator],
   provides: ICommandPalette,
-  activate: (app: JupyterFrontEnd, translator: ITranslator) => {
-    return Palette.activate(app, translator);
+  optional: [ISettingRegistry],
+  activate: (
+    app: JupyterFrontEnd,
+    translator: ITranslator,
+    settingRegistry: ISettingRegistry | null
+  ) => {
+    return Palette.activate(app, translator, settingRegistry);
   }
 };
 

+ 55 - 6
packages/apputils-extension/src/palette.ts

@@ -4,13 +4,25 @@
 |----------------------------------------------------------------------------*/
 
 import { find } from '@lumino/algorithm';
+
 import { CommandRegistry } from '@lumino/commands';
+
 import { DisposableDelegate, IDisposable } from '@lumino/disposable';
+
 import { CommandPalette } from '@lumino/widgets';
 
 import { ILayoutRestorer, JupyterFrontEnd } from '@jupyterlab/application';
-import { ICommandPalette, IPaletteItem } from '@jupyterlab/apputils';
+
+import {
+  ICommandPalette,
+  IPaletteItem,
+  ModalCommandPalette
+} from '@jupyterlab/apputils';
+
 import { ITranslator, nullTranslator } from '@jupyterlab/translation';
+
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
+
 import { CommandPaletteSvg, paletteIcon } from '@jupyterlab/ui-components';
 
 /**
@@ -20,6 +32,8 @@ namespace CommandIDs {
   export const activate = 'apputils:activate-command-palette';
 }
 
+const PALETTE_PLUGIN_ID = '@jupyterlab/apputils-extension:palette';
+
 /**
  * A thin wrapper around the `CommandPalette` class to conform with the
  * JupyterLab interface for the application-wide command palette.
@@ -80,11 +94,45 @@ export namespace Palette {
    */
   export function activate(
     app: JupyterFrontEnd,
-    translator: ITranslator
+    translator: ITranslator,
+    settingRegistry: ISettingRegistry | null
   ): ICommandPalette {
     const { commands, shell } = app;
     const trans = translator.load('jupyterlab');
     const palette = Private.createPalette(app, translator);
+    const modalPalette = new ModalCommandPalette({ commandPalette: palette });
+    let modal = false;
+
+    shell.add(palette, 'left', { rank: 300 });
+
+    if (settingRegistry) {
+      const loadSettings = settingRegistry.load(PALETTE_PLUGIN_ID);
+      const updateSettings = (settings: ISettingRegistry.ISettings): void => {
+        const newModal = settings.get('modal').composite as boolean;
+        if (modal && !newModal) {
+          palette.parent = null;
+          modalPalette.detach();
+          shell.add(palette, 'left', { rank: 300 });
+        } else if (!modal && newModal) {
+          palette.parent = null;
+          modalPalette.palette = palette;
+          palette.show();
+          modalPalette.attach();
+        }
+        modal = newModal;
+      };
+
+      Promise.all([loadSettings, app.restored])
+        .then(([settings]) => {
+          updateSettings(settings);
+          settings.changed.connect(settings => {
+            updateSettings(settings);
+          });
+        })
+        .catch((reason: Error) => {
+          console.error(reason.message);
+        });
+    }
 
     // Show the current palette shortcut in its title.
     const updatePaletteTitle = () => {
@@ -106,15 +154,17 @@ export namespace Palette {
 
     commands.addCommand(CommandIDs.activate, {
       execute: () => {
-        shell.activateById(palette.id);
+        if (modal) {
+          modalPalette.activate();
+        } else {
+          shell.activateById(palette.id);
+        }
       },
       label: trans.__('Activate Command Palette')
     });
 
     palette.inputNode.placeholder = trans.__('SEARCH');
 
-    shell.add(palette, 'left', { rank: 300 });
-
     return new Palette(palette, translator);
   }
 
@@ -127,7 +177,6 @@ export namespace Palette {
     translator: ITranslator
   ): void {
     const palette = Private.createPalette(app, translator);
-
     // Let the application restorer track the command palette for restoration of
     // application state (e.g. setting the command palette as the current side bar
     // widget).

+ 121 - 1
packages/apputils/src/commandpalette.ts

@@ -7,7 +7,9 @@ import { Token } from '@lumino/coreutils';
 
 import { IDisposable } from '@lumino/disposable';
 
-import { CommandPalette } from '@lumino/widgets';
+import { Message } from '@lumino/messaging';
+
+import { CommandPalette, Widget, Panel } from '@lumino/widgets';
 
 /* tslint:disable */
 /**
@@ -46,3 +48,121 @@ export interface ICommandPalette {
    */
   addItem(options: IPaletteItem): IDisposable;
 }
+
+/**
+ * Wrap the command palette in a modal to make it more usable.
+ */
+export class ModalCommandPalette extends Panel {
+  constructor(options: ModalCommandPalette.IOptions) {
+    super();
+    this.addClass('jp-ModalCommandPalette');
+    this.id = 'modal-command-palette';
+    this._commandPalette = options.commandPalette;
+    this.addWidget(this._commandPalette);
+    this._commandPalette.commands.commandExecuted.connect(() => {
+      if (this.isAttached && this.isVisible) {
+        this.hideAndReset();
+      }
+    });
+    this.hideAndReset();
+  }
+
+  get palette(): CommandPalette {
+    return this._commandPalette;
+  }
+
+  set palette(value: CommandPalette) {
+    this._commandPalette = value;
+    this.addWidget(value);
+    this.hideAndReset();
+  }
+
+  attach(): void {
+    Widget.attach(this, document.body);
+  }
+
+  detach(): void {
+    Widget.detach(this);
+  }
+
+  /**
+   * Hide the modal command palette and reset its search.
+   */
+  hideAndReset(): void {
+    this.hide();
+    this._commandPalette.inputNode.value = '';
+    this._commandPalette.refresh();
+  }
+
+  /**
+   * Handle incoming events.
+   */
+  handleEvent(event: Event): void {
+    switch (event.type) {
+      case 'keydown':
+        this._evtKeydown(event as KeyboardEvent);
+        break;
+      case 'blur':
+        this.hideAndReset();
+        break;
+      case 'contextmenu':
+        event.preventDefault();
+        event.stopPropagation();
+        break;
+      default:
+        break;
+    }
+  }
+
+  /**
+   *  A message handler invoked on an `'after-attach'` message.
+   */
+  protected onAfterAttach(msg: Message): void {
+    this.node.addEventListener('keydown', this, true);
+    this.node.addEventListener('contextmenu', this, true);
+    this.node.addEventListener('blur', this, true);
+  }
+
+  /**
+   *  A message handler invoked on an `'after-detach'` message.
+   */
+  protected onAfterDetach(msg: Message): void {
+    this.node.removeEventListener('keydown', this, true);
+    this.node.removeEventListener('contextmenu', this, true);
+    this.node.removeEventListener('blur', this, true);
+  }
+
+  /**
+   * A message handler invoked on an `'activate-request'` message.
+   */
+  protected onActivateRequest(msg: Message): void {
+    if (this.isAttached) {
+      this.show();
+      this._commandPalette.activate();
+    }
+  }
+
+  /**
+   * Handle the `'keydown'` event for the widget.
+   */
+  protected _evtKeydown(event: KeyboardEvent): void {
+    // Check for escape key
+    switch (event.keyCode) {
+      case 27: // Escape.
+        event.stopPropagation();
+        event.preventDefault();
+        this.hideAndReset();
+        break;
+      default:
+        break;
+    }
+  }
+
+  private _commandPalette: CommandPalette;
+}
+
+export namespace ModalCommandPalette {
+  export interface IOptions {
+    commandPalette: CommandPalette;
+  }
+}

+ 40 - 0
packages/apputils/style/commandpalette.css

@@ -24,6 +24,46 @@
   font-size: var(--jp-ui-font-size1);
 }
 
+/*-----------------------------------------------------------------------------
+| Modal variant
+|----------------------------------------------------------------------------*/
+
+.jp-ModalCommandPalette {
+  position: absolute;
+  z-index: 10000;
+  top: 38px;
+  left: 30%;
+  margin: 0;
+  padding: 4px;
+  width: 40%;
+  box-shadow: var(--jp-elevation-z4);
+  border-radius: 4px;
+  background: var(--jp-layout-color0);
+}
+
+.jp-ModalCommandPalette .lm-CommandPalette {
+  max-height: 40vh;
+}
+
+.jp-ModalCommandPalette .lm-CommandPalette .lm-close-icon::after {
+  display: none;
+}
+
+.jp-ModalCommandPalette .lm-CommandPalette .lm-CommandPalette-header {
+  display: none;
+}
+
+.jp-ModalCommandPalette .lm-CommandPalette .lm-CommandPalette-item {
+  margin-left: 4px;
+  margin-right: 4px;
+}
+
+.jp-ModalCommandPalette
+  .lm-CommandPalette
+  .lm-CommandPalette-item.lm-mod-disabled {
+  display: none;
+}
+
 /*-----------------------------------------------------------------------------
 | Search
 |----------------------------------------------------------------------------*/

+ 114 - 0
packages/apputils/test/commandpalette.spec.ts

@@ -0,0 +1,114 @@
+// Copyright (c) Jupyter Development Team.
+
+import 'jest';
+// Distributed under the terms of the Modified BSD License.
+
+// import { expect } from 'chai';
+
+import { CommandRegistry } from '@lumino/commands';
+
+import { JSONObject } from '@lumino/coreutils';
+
+import { MessageLoop } from '@lumino/messaging';
+
+import { Widget } from '@lumino/widgets';
+
+import { CommandPalette } from '@lumino/widgets';
+
+import { CommandPaletteSvg, paletteIcon } from '@jupyterlab/ui-components';
+
+import { ModalCommandPalette } from '@jupyterlab/apputils';
+
+import { nullTranslator, ITranslator } from '@jupyterlab/translation';
+
+import { simulate } from 'simulate-event';
+
+describe('@jupyterlab/apputils', () => {
+  describe('ModalCommandPalette', () => {
+    let commands: CommandRegistry;
+    let translator: ITranslator;
+    let palette: CommandPalette;
+    let modalPalette: ModalCommandPalette;
+
+    beforeEach(() => {
+      commands = new CommandRegistry();
+      translator = nullTranslator;
+      palette = new CommandPalette({
+        commands: commands,
+        renderer: CommandPaletteSvg.defaultRenderer
+      });
+      palette.id = 'command-palette';
+      palette.title.icon = paletteIcon;
+      const trans = translator.load('jupyterlab');
+      palette.title.label = trans.__('Commands');
+      modalPalette = new ModalCommandPalette({ commandPalette: palette });
+      modalPalette.attach();
+    });
+
+    describe('#constructor()', () => {
+      it('should create a new command palette', () => {
+        expect(palette).toBeInstanceOf(CommandPalette);
+      });
+      it('should create a new modal command palette', () => {
+        expect(modalPalette).toBeInstanceOf(ModalCommandPalette);
+      });
+      it('should attach to the document body', () => {
+        expect(document.body.contains(modalPalette.node)).toBe(true);
+      });
+      it('should start hidden', () => {
+        expect(modalPalette.isHidden).toBe(true);
+      });
+    });
+
+    describe('#activate()', () => {
+      it('should become visible when activated', () => {
+        MessageLoop.sendMessage(modalPalette, Widget.Msg.ActivateRequest);
+        expect(modalPalette.isVisible).toBe(true);
+      });
+    });
+
+    describe('#hideAndReset()', () => {
+      it('should become hidden and clear the input when calling hideAndReset', () => {
+        MessageLoop.sendMessage(modalPalette, Widget.Msg.ActivateRequest);
+        palette.inputNode.value = 'Search string...';
+        modalPalette.hideAndReset();
+        expect(modalPalette.isVisible).toBe(false);
+        expect(palette.inputNode.value).toEqual('');
+      });
+    });
+
+    describe('#blur()', () => {
+      it('should hide and reset when blurred', () => {
+        MessageLoop.sendMessage(modalPalette, Widget.Msg.ActivateRequest);
+        palette.inputNode.value = 'Search string...';
+        simulate(modalPalette.node, 'blur');
+        expect(modalPalette.isVisible).toBe(false);
+        expect(palette.inputNode.value).toEqual('');
+      });
+    });
+
+    describe('#escape()', () => {
+      it('should hide and reset when ESC is pressed', () => {
+        MessageLoop.sendMessage(modalPalette, Widget.Msg.ActivateRequest);
+        palette.inputNode.value = 'Search string...';
+        simulate(modalPalette.node, 'keydown', { keyCode: 27 });
+        expect(modalPalette.isVisible).toBe(false);
+        expect(palette.inputNode.value).toEqual('');
+      });
+    });
+
+    describe('#execute()', () => {
+      it('should hide and reset when a command is executed', () => {
+        commands.addCommand('mock-command', {
+          execute: (args: JSONObject) => {
+            return args;
+          }
+        });
+        MessageLoop.sendMessage(modalPalette, Widget.Msg.ActivateRequest);
+        void commands.execute('mock-command');
+        expect(modalPalette.isVisible).toBe(false);
+        expect(palette.inputNode.value).toEqual('');
+      });
+    });
+  });
+});