Bug 1561873 - Support for keyboard navigation in WS frame list. r=Honza
authortanhengyeow <E0032242@u.nus.edu>
Thu, 05 Sep 2019 07:59:18 +0000
changeset 491821 fc2a5d551b3c41009c5eeb8f3598339e39c53118
parent 491820 ecdf8f05db28e90c81bf8358c1dc8042d05efa01
child 491822 d8a4d4ed79a9ba3fd31fd44379d9b53e245ea737
push id94531
push userjodvarko@mozilla.com
push dateThu, 05 Sep 2019 08:02:13 +0000
treeherderautoland@fc2a5d551b3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1561873
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1561873 - Support for keyboard navigation in WS frame list. r=Honza Support for keyboard navigation in WS frame list. Differential Revision: https://phabricator.services.mozilla.com/D42897
devtools/client/netmonitor/src/actions/web-sockets.js
devtools/client/netmonitor/src/components/websockets/FrameListContent.js
--- a/devtools/client/netmonitor/src/actions/web-sockets.js
+++ b/devtools/client/netmonitor/src/actions/web-sockets.js
@@ -10,16 +10,19 @@ const {
   WS_OPEN_FRAME_DETAILS,
   WS_CLEAR_FRAMES,
   WS_TOGGLE_FRAME_FILTER_TYPE,
   WS_SET_REQUEST_FILTER_TEXT,
   WS_TOGGLE_COLUMN,
   WS_RESET_COLUMNS,
 } = require("../constants");
 
+const { getDisplayedFrames } = require("../selectors/index");
+const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
+
 /**
  * Add frame into state.
  */
 function addFrame(httpChannelId, data, batch) {
   return {
     type: WS_ADD_FRAME,
     httpChannelId,
     data,
@@ -99,18 +102,50 @@ function resetWebSocketsColumns() {
  */
 function toggleWebSocketsColumn(column) {
   return {
     type: WS_TOGGLE_COLUMN,
     column,
   };
 }
 
+/**
+ * Move the selection up to down according to the "delta" parameter. Possible values:
+ * - Number: positive or negative, move up or down by specified distance
+ * - "PAGE_UP" | "PAGE_DOWN" (String): page up or page down
+ * - +Infinity | -Infinity: move to the start or end of the list
+ */
+function selectFrameDelta(delta) {
+  return (dispatch, getState) => {
+    const state = getState();
+    const frames = getDisplayedFrames(state);
+
+    if (frames.length === 0) {
+      return;
+    }
+
+    const selIndex = frames.findIndex(
+      r => r === state.webSockets.selectedFrame
+    );
+
+    if (delta === "PAGE_DOWN") {
+      delta = Math.ceil(frames.length / PAGE_SIZE_ITEM_COUNT_RATIO);
+    } else if (delta === "PAGE_UP") {
+      delta = -Math.ceil(frames.length / PAGE_SIZE_ITEM_COUNT_RATIO);
+    }
+
+    const newIndex = Math.min(Math.max(0, selIndex + delta), frames.length - 1);
+    const newItem = frames[newIndex];
+    dispatch(selectFrame(newItem));
+  };
+}
+
 module.exports = {
   addFrame,
   selectFrame,
   openFrameDetails,
   clearFrames,
   toggleFrameFilterType,
   setFrameFilterText,
   resetWebSocketsColumns,
   toggleWebSocketsColumn,
+  selectFrameDelta,
 };
--- a/devtools/client/netmonitor/src/components/websockets/FrameListContent.js
+++ b/devtools/client/netmonitor/src/components/websockets/FrameListContent.js
@@ -46,23 +46,25 @@ class FrameListContent extends Component
     return {
       connector: PropTypes.object.isRequired,
       startPanelContainer: PropTypes.object,
       frames: PropTypes.array,
       selectedFrame: PropTypes.object,
       selectFrame: PropTypes.func.isRequired,
       columns: PropTypes.object.isRequired,
       channelId: PropTypes.number,
+      onSelectFrameDelta: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.onContextMenu = this.onContextMenu.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
     this.framesLimit = Services.prefs.getIntPref(
       "devtools.netmonitor.ws.displayed-frames.limit"
     );
     this.currentTruncatedNum = 0;
     this.state = {
       checked: false,
     };
     this.pinnedToBottom = false;
@@ -91,17 +93,47 @@ class FrameListContent extends Component
       this.initIntersectionObserver = false;
     }
 
     // If a new WebSocket connection is selected, scroll to anchor.
     if (channelId !== prevProps.channelId && scrollAnchor) {
       scrollAnchor.scrollIntoView();
     }
 
-    this.setupScrollToBottom(startPanelContainer, scrollAnchor);
+    // Do not autoscroll if the selection changed. This would cause
+    // the newly selected frame to jump just after clicking in.
+    // (not user friendly)
+    //
+    // If the selection changed, we need to ensure that the newly
+    // selected frame is properly scrolled into the visible area.
+    if (prevProps.selectedFrame === this.props.selectedFrame) {
+      this.setupScrollToBottom(startPanelContainer, scrollAnchor);
+    } else {
+      const head = document.querySelector("thead.ws-frames-list-headers-group");
+      const selectedRow = document.querySelector(
+        "tr.ws-frame-list-item.selected"
+      );
+
+      if (selectedRow) {
+        const rowRect = selectedRow.getBoundingClientRect();
+        const scrollableRect = startPanelContainer.getBoundingClientRect();
+        const headRect = head.getBoundingClientRect();
+
+        if (rowRect.top <= scrollableRect.top) {
+          selectedRow.scrollIntoView(true);
+
+          // We need to scroll a bit more to get the row out
+          // of the header. The header is sticky and overlaps
+          // part of the scrollable area.
+          startPanelContainer.scrollTop -= headRect.height;
+        } else if (rowRect.bottom > scrollableRect.bottom) {
+          selectedRow.scrollIntoView(false);
+        }
+      }
+    }
   }
 
   componentWillUnmount() {
     // Reset observables and boolean values.
     const scrollAnchor = this.refs.scrollAnchor;
 
     if (this.intersectionObserver) {
       this.intersectionObserver.unobserve(scrollAnchor);
@@ -154,16 +186,53 @@ class FrameListContent extends Component
     evt.preventDefault();
     const { connector } = this.props;
     this.contextMenu = new FrameListContextMenu({
       connector,
     });
     this.contextMenu.open(evt, item);
   }
 
+  /**
+   * Handler for keyboard events. For arrow up/down, page up/down, home/end,
+   * move the selection up or down.
+   */
+  onKeyDown(evt) {
+    evt.preventDefault();
+    evt.stopPropagation();
+    let delta;
+
+    switch (evt.key) {
+      case "ArrowUp":
+      case "ArrowLeft":
+        delta = -1;
+        break;
+      case "ArrowDown":
+      case "ArrowRight":
+        delta = +1;
+        break;
+      case "PageUp":
+        delta = "PAGE_UP";
+        break;
+      case "PageDown":
+        delta = "PAGE_DOWN";
+        break;
+      case "Home":
+        delta = -Infinity;
+        break;
+      case "End":
+        delta = +Infinity;
+        break;
+    }
+
+    if (delta) {
+      this.props.onSelectFrameDelta(delta);
+    }
+  }
+
   render() {
     const { frames, selectedFrame, connector, columns } = this.props;
 
     if (frames.length === 0) {
       return div(
         { className: "empty-notice ws-frame-list-empty-notice" },
         FRAMES_EMPTY_TEXT
       );
@@ -195,16 +264,17 @@ class FrameListContent extends Component
     return div(
       {},
       table(
         { className: "ws-frames-list-table" },
         FrameListHeader(),
         tbody(
           {
             className: "ws-frames-list-body",
+            onKeyDown: this.onKeyDown,
           },
           tr(
             {
               tabIndex: 0,
             },
             td(
               {
                 className: "truncated-messages-cell",
@@ -272,10 +342,11 @@ class FrameListContent extends Component
 module.exports = connect(
   state => ({
     selectedFrame: getSelectedFrame(state),
     frames: getDisplayedFrames(state),
     columns: state.webSockets.columns,
   }),
   dispatch => ({
     selectFrame: item => dispatch(Actions.selectFrame(item)),
+    onSelectFrameDelta: delta => dispatch(Actions.selectFrameDelta(delta)),
   })
 )(FrameListContent);