Browse Source

Switch to using anchor element option.

A. Darian 8 năm trước cách đây
mục cha
commit
e84e6df457

+ 1 - 0
examples/console/src/index.ts

@@ -36,6 +36,7 @@ import {
 
 import 'jupyterlab/lib/notebook/index.css';
 import 'jupyterlab/lib/notebook/theme.css';
+import 'jupyterlab/lib/notebook/completion/index.css';
 import 'jupyterlab/lib/dialog/index.css';
 import 'jupyterlab/lib/dialog/theme.css';
 

+ 1 - 0
examples/notebook/src/index.ts

@@ -49,6 +49,7 @@ import {
 
 import 'jupyterlab/lib/notebook/index.css';
 import 'jupyterlab/lib/notebook/theme.css';
+import 'jupyterlab/lib/notebook/completion/index.css';
 import 'jupyterlab/lib/dialog/index.css';
 import 'jupyterlab/lib/dialog/theme.css';
 

+ 1 - 1
src/console/widget.ts

@@ -246,7 +246,7 @@ class ConsoleWidget extends Widget {
 
     // Instantiate tab completion widget.
     this._completion = constructor.createCompletion();
-    this._completion.reference = this;
+    this._completion.anchor = this.node;
     this._completion.attach(document.body);
     this._completionHandler = new CellCompletionHandler(this._completion);
     this._completionHandler.kernel = session.kernel;

+ 1 - 0
src/index.css

@@ -5,4 +5,5 @@
 |----------------------------------------------------------------------------*/
 @import './dialog/index.css';
 @import './filebrowser/index.css';
+@import './notebook/completion/index.css'
 @import './terminal/index.css';

+ 132 - 101
src/notebook/completion/widget.ts

@@ -32,6 +32,11 @@ const ITEM_CLASS = 'jp-Completion-item';
  */
 const ACTIVE_CLASS = 'jp-mod-active';
 
+/**
+ * The class name added to a completion widget that is scrolled out of view.
+ */
+const OUTOFVIEW_CLASS = 'jp-mod-outofview'
+
 /**
  * The maximum height of a completion widget.
  */
@@ -62,7 +67,7 @@ class CompletionWidget extends Widget {
   constructor(options: CompletionWidget.IOptions = {}) {
     super();
     this._renderer = options.renderer || CompletionWidget.defaultRenderer;
-    this._reference = options.reference || null;
+    this._anchor = options.anchor || null;
     this.model = options.model || null;
     this.addClass(COMPLETION_CLASS);
   }
@@ -98,13 +103,17 @@ class CompletionWidget extends Widget {
   }
 
   /**
-   * The semantic parent of the completion widget, its reference widget.
+   * The semantic parent of the completion widget, its anchor element. An
+   * event listener will peg the position of the completion widget to the
+   * anchor element's scroll position. Other event listeners will guarantee
+   * the completion widget behaves like a child of the reference element even
+   * if it does not appear as a descendant in the DOM.
    */
-  get reference(): Widget {
-    return this._reference;
+  get anchor(): HTMLElement {
+    return this._anchor;
   }
-  set reference(widget: Widget) {
-    this._reference = widget;
+  set anchor(element: HTMLElement) {
+    this._anchor = element;
   }
 
   /**
@@ -129,7 +138,7 @@ class CompletionWidget extends Widget {
    * not be called directly by user code.
    */
   handleEvent(event: Event): void {
-    if (this.isHidden || !this._reference) {
+    if (this.isHidden || !this._anchor) {
       return;
     }
     switch (event.type) {
@@ -152,16 +161,15 @@ class CompletionWidget extends Widget {
    *
    * #### Notes
    * Captures window events in capture phase to dismiss or navigate the
-   * completion widget.
-   *
-   * Because its parent (reference) widgets use window listeners instead of
-   * document listeners, the completion widget must also use window listeners
-   * in the capture phase.
+   * completion widget. Captures scroll events on the anchor element to
+   * peg the completion widget's scroll position to the anchor.
    */
   protected onAfterAttach(msg: Message): void {
     window.addEventListener('keydown', this, USE_CAPTURE);
     window.addEventListener('mousedown', this, USE_CAPTURE);
-    window.addEventListener('scroll', this, USE_CAPTURE);
+    if (this._anchor) {
+      this._anchor.addEventListener('scroll', this, USE_CAPTURE);
+    }
   }
 
   /**
@@ -170,7 +178,18 @@ class CompletionWidget extends Widget {
   protected onBeforeDetach(msg: Message): void {
     window.removeEventListener('keydown', this, USE_CAPTURE);
     window.removeEventListener('mousedown', this, USE_CAPTURE);
-    window.removeEventListener('scroll', this, USE_CAPTURE);
+    if (this._anchor) {
+      this._anchor.removeEventListener('scroll', this, USE_CAPTURE);
+    }
+  }
+
+  /**
+   * Handle model state changes.
+   */
+  protected onModelStateChanged(): void {
+    if (this.isAttached) {
+      this.update();
+    }
   }
 
   /**
@@ -213,34 +232,70 @@ class CompletionWidget extends Widget {
     if (this.isHidden) {
       this.show();
     }
+    this._setGeometry();
+  }
 
-    let coords = this._model.current ? this._model.current.coords
-      : this._model.original.coords;
-    let availableHeight = coords.top;
-    let maxHeight = Math.min(availableHeight, MAX_HEIGHT);
-    node.style.maxHeight = `${maxHeight}px`;
-
-    // Account for 1px border width.
-    let left = Math.floor(coords.left) + 1;
-    let rect = node.getBoundingClientRect();
-    let top = availableHeight - rect.height;
-    node.style.left = `${left}px`;
-    node.style.top = `${top}px`;
-    node.style.width = 'auto';
-    // Expand the menu width by the scrollbar size, if present.
-    if (node.scrollHeight > maxHeight) {
-      node.style.width = `${2 * node.offsetWidth - node.clientWidth}px`;
-      node.scrollTop = 0;
+  /**
+   * Cycle through the available completion items.
+   */
+  private _cycle(direction: 'up' | 'down'): void {
+    let items = this.node.querySelectorAll(`.${ITEM_CLASS}`);
+    let index = this._activeIndex;
+    let active = this.node.querySelector(`.${ACTIVE_CLASS}`) as HTMLElement;
+    active.classList.remove(ACTIVE_CLASS);
+    if (direction === 'up') {
+      this._activeIndex = index === 0 ? items.length - 1 : index - 1;
+    } else {
+      this._activeIndex = index < items.length - 1 ? index + 1 : 0;
     }
+    active = items[this._activeIndex] as HTMLElement;
+    active.classList.add(ACTIVE_CLASS);
+    Private.scrollIfNeeded(this.node, active);
   }
 
   /**
-   * Handle model state changes.
+   * Handle keydown events for the widget.
    */
-  protected onModelStateChanged(): void {
-    if (this.isAttached) {
-      this.update();
+  private _evtKeydown(event: KeyboardEvent) {
+    let target = event.target as HTMLElement;
+    while (target !== document.documentElement) {
+      if (target === this._anchor) {
+        switch (event.keyCode) {
+          case 9:  // Tab key
+            event.preventDefault();
+            event.stopPropagation();
+            event.stopImmediatePropagation();
+            if (this._populateSubset()) {
+              return;
+            }
+            this._selectActive();
+            return;
+          case 13: // Enter key
+            event.preventDefault();
+            event.stopPropagation();
+            event.stopImmediatePropagation();
+            this._selectActive();
+            return;
+          case 27: // Escape key
+            event.preventDefault();
+            event.stopPropagation();
+            event.stopImmediatePropagation();
+            this._reset();
+            return;
+          case 38: // Up arrow key
+          case 40: // Down arrow key
+            event.preventDefault();
+            event.stopPropagation();
+            event.stopImmediatePropagation();
+            this._cycle(event.keyCode === 38 ? 'up' : 'down');
+            return;
+          default:
+            return;
+        }
+      }
+      target = target.parentElement;
     }
+    this._reset();
   }
 
   /**
@@ -275,51 +330,6 @@ class CompletionWidget extends Widget {
     this._reset();
   }
 
-  /**
-   * Handle keydown events for the widget.
-   */
-  private _evtKeydown(event: KeyboardEvent) {
-    let target = event.target as HTMLElement;
-    while (target !== document.documentElement) {
-      if (target === this._reference.node) {
-        switch (event.keyCode) {
-        case 9:  // Tab key
-          event.preventDefault();
-          event.stopPropagation();
-          event.stopImmediatePropagation();
-          if (this._populateSubset()) {
-            return;
-          }
-          this._selectActive();
-          return;
-        case 13: // Enter key
-          event.preventDefault();
-          event.stopPropagation();
-          event.stopImmediatePropagation();
-          this._selectActive();
-          return;
-        case 27: // Escape key
-          event.preventDefault();
-          event.stopPropagation();
-          event.stopImmediatePropagation();
-          this._reset();
-          return;
-        case 38: // Up arrow key
-        case 40: // Down arrow key
-          event.preventDefault();
-          event.stopPropagation();
-          event.stopImmediatePropagation();
-          this._cycle(event.keyCode === 38 ? 'up' : 'down');
-          return;
-        default:
-          return;
-        }
-      }
-      target = target.parentElement;
-    }
-    this._reset();
-  }
-
   /**
    * Handle scroll events for the widget
    */
@@ -338,24 +348,6 @@ class CompletionWidget extends Widget {
     this._reset();
   }
 
-  /**
-   * Cycle through the available completion items.
-   */
-  private _cycle(direction: 'up' | 'down'): void {
-    let items = this.node.querySelectorAll(`.${ITEM_CLASS}`);
-    let index = this._activeIndex;
-    let active = this.node.querySelector(`.${ACTIVE_CLASS}`) as HTMLElement;
-    active.classList.remove(ACTIVE_CLASS);
-    if (direction === 'up') {
-      this._activeIndex = index === 0 ? items.length - 1 : index - 1;
-    } else {
-      this._activeIndex = index < items.length - 1 ? index + 1 : 0;
-    }
-    active = items[this._activeIndex] as HTMLElement;
-    active.classList.add(ACTIVE_CLASS);
-    Private.scrollIfNeeded(this.node, active);
-  }
-
   /**
    * Populate the completion up to the longest initial subset of items.
    *
@@ -382,6 +374,40 @@ class CompletionWidget extends Widget {
       this._model.reset();
     }
     this._activeIndex = 0;
+    this._scrollDelta = 0;
+  }
+
+  /**
+   * Set the visible dimensions of the widget.
+   */
+  private _setGeometry(): void {
+    let node = this.node;
+    let coords = this._model.current ? this._model.current.coords
+      : this._model.original.coords;
+    let availableHeight = coords.top;
+    let maxHeight = Math.max(0, Math.min(availableHeight, MAX_HEIGHT));
+
+    if (maxHeight) {
+      node.classList.remove(OUTOFVIEW_CLASS);
+    } else {
+      node.classList.add(OUTOFVIEW_CLASS);
+      return;
+    }
+    node.style.maxHeight = `${maxHeight}px`;
+
+    // Account for 1px border width.
+    let left = Math.floor(coords.left) + 1;
+    let rect = node.getBoundingClientRect();
+    let top = availableHeight - rect.height;
+    node.style.left = `${left}px`;
+    node.style.top = `${top}px`;
+    node.style.width = 'auto';
+
+    // Expand the menu width by the scrollbar size, if present.
+    if (node.scrollHeight > maxHeight) {
+      node.style.width = `${2 * node.offsetWidth - node.clientWidth}px`;
+      node.scrollTop = 0;
+    }
   }
 
   /**
@@ -396,10 +422,11 @@ class CompletionWidget extends Widget {
     this._reset();
   }
 
+  private _anchor: HTMLElement = null;
   private _activeIndex = 0;
   private _model: ICompletionModel = null;
-  private _reference: Widget = null;
   private _renderer: CompletionWidget.IRenderer = null;
+  private _scrollDelta = 0;
 }
 
 
@@ -416,9 +443,13 @@ namespace CompletionWidget {
     model?: ICompletionModel;
 
     /**
-     * The semantic parent of the completion widget, its reference widget.
+     * The semantic parent of the completion widget, its anchor element. An
+     * event listener will peg the position of the completion widget to the
+     * anchor element's scroll position. Other event listeners will guarantee
+     * the completion widget behaves like a child of the reference element even
+     * if it does not appear as a descendant in the DOM.
      */
-    reference?: Widget;
+    anchor?: HTMLElement;
 
     /**
      * The renderer for the completion widget nodes.

+ 3 - 1
src/notebook/notebook/panel.ts

@@ -99,7 +99,9 @@ class NotebookPanel extends Widget {
     layout.addChild(container);
 
     this._completion = this._renderer.createCompletion();
-    this._completion.reference = this;
+    // The completion widget's anchor node is the node whose scrollTop is
+    // pegged to the completion widget's position.
+    this._completion.anchor = container.node;
     this._completion.attach(document.body);
 
     this._completionHandler = new CellCompletionHandler(this._completion);

+ 73 - 56
test/src/notebook/completion/widget.spec.ts

@@ -102,14 +102,15 @@ describe('notebook/completion/widget', () => {
     describe('#selected', () => {
 
       it('should emit a signal when an item is selected', () => {
+        let anchor = new Widget();
         let options: CompletionWidget.IOptions = {
           model: new CompletionModel(),
-          reference: new Widget()
+          anchor: anchor.node
         };
         let value = '';
         let listener = (sender: any, selected: string) => { value = selected; };
         options.model.options = ['foo', 'bar'];
-        options.reference.attach(document.body);
+        anchor.attach(document.body);
 
         let widget = new CompletionWidget(options);
 
@@ -117,10 +118,10 @@ describe('notebook/completion/widget', () => {
         widget.attach(document.body);
         sendMessage(widget, Widget.MsgUpdateRequest);
         expect(value).to.be('');
-        simulate(options.reference.node, 'keydown', { keyCode: 13 }); // Enter
+        simulate(anchor.node, 'keydown', { keyCode: 13 }); // Enter
         expect(value).to.be('foo');
         widget.dispose();
-        options.reference.dispose();
+        anchor.dispose();
       });
 
     });
@@ -157,26 +158,26 @@ describe('notebook/completion/widget', () => {
 
     });
 
-    describe('#reference', () => {
+    describe('#anchor', () => {
 
       it('should default to null', () => {
         let widget = new CompletionWidget();
-        expect(widget.reference).to.be(null);
+        expect(widget.anchor).to.be(null);
       });
 
       it('should be settable', () => {
         let widget = new CompletionWidget();
-        expect(widget.reference).to.be(null);
-        widget.reference = new Widget();
-        expect(widget.reference).to.be.a(Widget);
+        expect(widget.anchor).to.be(null);
+        widget.anchor = new Widget().node;
+        expect(widget.anchor).to.be.a(Node);
       });
 
       it('should be safe to reset', () => {
-        let reference = new Widget();
-        let widget = new CompletionWidget({ reference: new Widget() });
-        expect(widget.reference).not.to.be(reference);
-        widget.reference = reference;
-        expect(widget.reference).to.be(reference);
+        let anchor = new Widget();
+        let widget = new CompletionWidget({ anchor: (new Widget()).node });
+        expect(widget.anchor).not.to.be(anchor.node);
+        widget.anchor = anchor.node;
+        expect(widget.anchor).to.be(anchor.node);
       });
 
     });
@@ -200,10 +201,10 @@ describe('notebook/completion/widget', () => {
 
     describe('#handleEvent()', () => {
 
-      it('should handle window keydown, mousedown, and scroll events', () => {
+      it('should handle window keydown and mousedown events', () => {
         let widget = new LogWidget();
         widget.attach(document.body);
-        ['keydown', 'mousedown', 'scroll'].forEach(type => {
+        ['keydown', 'mousedown'].forEach(type => {
           simulate(window, type);
           expect(widget.events).to.contain(type);
         });
@@ -212,12 +213,14 @@ describe('notebook/completion/widget', () => {
 
       context('keydown', () => {
 
-        it('should reset if keydown is outside reference', () => {
-          let reference = new Widget();
+        it('should reset if keydown is outside anchor', () => {
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           model.options = ['foo', 'bar'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -230,15 +233,17 @@ describe('notebook/completion/widget', () => {
           expect(widget.isHidden).to.be(true);
           expect(model.options).to.not.be.ok();
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should reset on escape key', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           model.options = ['foo', 'bar'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -246,24 +251,26 @@ describe('notebook/completion/widget', () => {
           sendMessage(widget, Widget.MsgUpdateRequest);
           expect(widget.isHidden).to.be(false);
           expect(model.options).to.be.ok();
-          simulate(reference.node, 'keydown', { keyCode: 27 }); // Escape
+          simulate(anchor.node, 'keydown', { keyCode: 27 }); // Escape
           sendMessage(widget, Widget.MsgUpdateRequest);
           expect(widget.isHidden).to.be(true);
           expect(model.options).to.not.be.ok();
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should trigger a selected signal on enter key', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           let value = '';
           let listener = (sender: any, selected: string) => {
             value = selected;
           };
           model.options = ['foo', 'bar', 'baz'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -271,23 +278,25 @@ describe('notebook/completion/widget', () => {
           widget.attach(document.body);
           sendMessage(widget, Widget.MsgUpdateRequest);
           expect(value).to.be('');
-          simulate(reference.node, 'keydown', { keyCode: 13 }); // Enter
+          simulate(anchor.node, 'keydown', { keyCode: 13 }); // Enter
           expect(value).to.be('foo');
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should select the item below and cycle back on down', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           model.options = ['foo', 'bar', 'baz'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
           let target = document.createElement('div');
 
-          reference.node.appendChild(target);
+          anchor.node.appendChild(target);
           widget.attach(document.body);
           sendMessage(widget, Widget.MsgUpdateRequest);
 
@@ -309,15 +318,17 @@ describe('notebook/completion/widget', () => {
           expect(items[1].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[2].classList).to.not.contain(ACTIVE_CLASS);
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should select the item above and cycle back on up', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           model.options = ['foo', 'bar', 'baz'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -329,20 +340,20 @@ describe('notebook/completion/widget', () => {
           expect(items[0].classList).to.contain(ACTIVE_CLASS);
           expect(items[1].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[2].classList).to.not.contain(ACTIVE_CLASS);
-          simulate(reference.node, 'keydown', { keyCode: 38 }); // Up
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
           expect(items[0].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[1].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[2].classList).to.contain(ACTIVE_CLASS);
-          simulate(reference.node, 'keydown', { keyCode: 38 }); // Up
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
           expect(items[0].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[1].classList).to.contain(ACTIVE_CLASS);
           expect(items[2].classList).to.not.contain(ACTIVE_CLASS);
-          simulate(reference.node, 'keydown', { keyCode: 38 }); // Up
+          simulate(anchor.node, 'keydown', { keyCode: 38 }); // Up
           expect(items[0].classList).to.contain(ACTIVE_CLASS);
           expect(items[1].classList).to.not.contain(ACTIVE_CLASS);
           expect(items[2].classList).to.not.contain(ACTIVE_CLASS);
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
       });
@@ -350,16 +361,18 @@ describe('notebook/completion/widget', () => {
       context('mousedown', () => {
 
         it('should trigger a selected signal on mouse down', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           let value = '';
           let listener = (sender: any, selected: string) => {
             value = selected;
           };
           model.options = ['foo', 'bar', 'baz'];
           model.query = 'b';
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -373,19 +386,21 @@ describe('notebook/completion/widget', () => {
           simulate(item, 'mousedown');
           expect(value).to.be('baz');
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should ignore a mouse down that misses an item', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           let value = '';
           let listener = (sender: any, selected: string) => {
             value = selected;
           };
           model.options = ['foo', 'bar'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -396,19 +411,21 @@ describe('notebook/completion/widget', () => {
           simulate(widget.node, 'mousedown');
           expect(value).to.be('');
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
         it('should hide widget if mouse down misses it', () => {
-          let reference = new Widget();
+          let anchor = new Widget();
           let model = new CompletionModel();
-          let options: CompletionWidget.IOptions = { model, reference };
+          let options: CompletionWidget.IOptions = {
+            model, anchor: anchor.node
+          };
           let value = '';
           let listener = (sender: any, selected: string) => {
             value = selected;
           };
           model.options = ['foo', 'bar'];
-          reference.attach(document.body);
+          anchor.attach(document.body);
 
           let widget = new CompletionWidget(options);
 
@@ -416,11 +433,11 @@ describe('notebook/completion/widget', () => {
           widget.attach(document.body);
           sendMessage(widget, Widget.MsgUpdateRequest);
           expect(widget.isHidden).to.be(false);
-          simulate(options.reference.node, 'mousedown');
+          simulate(anchor.node, 'mousedown');
           sendMessage(widget, Widget.MsgUpdateRequest);
           expect(widget.isHidden).to.be(true);
           widget.dispose();
-          reference.dispose();
+          anchor.dispose();
         });
 
       });