Bug 1561873 - Support for keyboard navigation in WS frame list. r=Honza
☠☠ backed out by 598fa04e395e ☠ ☠
authortanhengyeow <E0032242@u.nus.edu>
Fri, 30 Aug 2019 17:02:46 +0000
changeset 490896 ae73cf1c7395dda33313fb7dbb56c8060be6174c
parent 490895 9057d052aceef4771b6717a4631f30cd887e3159
child 490897 a57d69ecbda8442e798d51cf8ecbd465737508c8
push id94125
push userjodvarko@mozilla.com
push dateFri, 30 Aug 2019 17:03:17 +0000
treeherderautoland@ae73cf1c7395 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1561873
milestone70.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;
@@ -78,30 +80,60 @@ class FrameListContent extends Component
     if (scrollAnchor) {
       // Always scroll to anchor when FrameListContent component first mounts.
       scrollAnchor.scrollIntoView();
     }
     this.setupScrollToBottom(startPanelContainer, scrollAnchor);
   }
 
   componentDidUpdate(prevProps) {
-    const { startPanelContainer, channelId } = this.props;
+    const { startPanelContainer, channelId, frames } = this.props;
     const scrollAnchor = this.refs.scrollAnchor;
 
     // When frames are cleared, the previous scrollAnchor would be destroyed, so we need to reset this boolean.
     if (!scrollAnchor) {
       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);