diff --git a/src/devtools/views/Components/Element.css b/src/devtools/views/Components/Element.css index 3353ede02a..2a923c15d9 100644 --- a/src/devtools/views/Components/Element.css +++ b/src/devtools/views/Components/Element.css @@ -1,6 +1,7 @@ .Element, .InactiveSelectedElement, -.SelectedElement { +.SelectedElement, +.HoveredElement { border-radius: 0.25em; white-space: nowrap; line-height: var(--line-height-data); @@ -9,7 +10,7 @@ cursor: default; user-select: none; } -.Element:hover { +.HoveredElement { background-color: var(--color-hover-background); } .InactiveSelectedElement { diff --git a/src/devtools/views/Components/Element.js b/src/devtools/views/Components/Element.js index 3cb0ddd04e..16fb243ff2 100644 --- a/src/devtools/views/Components/Element.js +++ b/src/devtools/views/Components/Element.js @@ -7,13 +7,14 @@ import React, { useLayoutEffect, useMemo, useRef, + useState, } from 'react'; import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types'; import Store from 'src/devtools/store'; import ButtonIcon from '../ButtonIcon'; import { createRegExp } from '../utils'; import { TreeContext } from './TreeContext'; -import { BridgeContext, StoreContext } from '../context'; +import { StoreContext } from '../context'; import type { ItemData } from './Tree'; import type { Element } from './types'; @@ -27,6 +28,7 @@ type Props = { }; export default function ElementView({ data, index, style }: Props) { + const [isHovered, setIsHovered] = useState(false); const { baseDepth, getElementAtIndex, @@ -35,15 +37,18 @@ export default function ElementView({ data, index, style }: Props) { selectedElementID, selectElementByID, } = useContext(TreeContext); - const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const element = getElementAtIndex(index); + const { + lastScrolledIDRef, + treeFocused, + isNavigatingWithKeyboard, + onElementMouseEnter, + } = data; const id = element === null ? null : element.id; const isSelected = selectedElementID === id; - const lastScrolledIDRef = data.lastScrolledIDRef; - const treeFocused = data.treeFocused; const handleDoubleClick = useCallback(() => { if (id !== null) { @@ -88,20 +93,16 @@ export default function ElementView({ data, index, style }: Props) { [id, selectElementByID] ); - const rendererID = id !== null ? store.getRendererIDForElement(id) : null; - // Individual elements don't have a corresponding leave handler. - // Instead, it's implemented on the tree level. const handleMouseEnter = useCallback(() => { - if (element !== null && id !== null && rendererID !== null) { - bridge.send('highlightElementInDOM', { - displayName: element.displayName, - hideAfterTimeout: false, - id, - rendererID, - scrollIntoView: false, - }); + setIsHovered(true); + if (id !== null) { + onElementMouseEnter(id); } - }, [bridge, element, id, rendererID]); + }, [onElementMouseEnter, id]); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); // Handle elements that are removed from the tree while an async render is in progress. if (element == null) { @@ -121,12 +122,15 @@ export default function ElementView({ data, index, style }: Props) { className = treeFocused ? styles.SelectedElement : styles.InactiveSelectedElement; + } else if (isHovered && !isNavigatingWithKeyboard) { + className = styles.HoveredElement; } return (
Element | null, + isNavigatingWithKeyboard: boolean, lastScrolledIDRef: { current: number | null }, + onElementMouseEnter: (id: number) => void, treeFocused: boolean, |}; @@ -50,6 +52,9 @@ export default function Tree(props: Props) { } = useContext(TreeContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); + const [isNavigatingWithKeyboard, setIsNavigatingWithKeyboard] = useState( + false + ); // $FlowFixMe https://github.com/facebook/flow/issues/7341 const listRef = useRef | null>(null); const treeRef = useRef(null); @@ -101,8 +106,6 @@ export default function Tree(props: Props) { } let element; - - // eslint-disable-next-line default-case switch (event.key) { case 'ArrowDown': event.preventDefault(); @@ -140,7 +143,10 @@ export default function Tree(props: Props) { event.preventDefault(); selectPreviousElementInTree(); break; + default: + return; } + setIsNavigatingWithKeyboard(true); }; // It's important to listen to the ownerDocument to support the browser extension. @@ -161,8 +167,8 @@ export default function Tree(props: Props) { store, ]); + // Focus management. const handleBlur = useCallback(() => setTreeFocused(false)); - const handleFocus = useCallback(() => { setTreeFocused(true); @@ -187,6 +193,53 @@ export default function Tree(props: Props) { [selectedElementID, selectOwner] ); + const highlightElementInDOM = useCallback( + (id: number) => { + const element = store.getElementByID(id); + const rendererID = store.getRendererIDForElement(id); + if (element !== null) { + bridge.send('highlightElementInDOM', { + displayName: element.displayName, + hideAfterTimeout: false, + id, + rendererID, + scrollIntoView: false, + }); + } + }, + [store, bridge] + ); + + // If we switch the selected element while using the keyboard, + // start highlighting it in the DOM instead of the last hovered node. + useEffect(() => { + if (isNavigatingWithKeyboard && selectedElementID !== null) { + highlightElementInDOM(selectedElementID); + } + }, [isNavigatingWithKeyboard, highlightElementInDOM, selectedElementID]); + + // Highlight last hovered element. + const handleElementMouseEnter = useCallback( + id => { + // Ignore hover while we're navigating with keyboard. + // This avoids flicker from the hovered nodes under the mouse. + if (!isNavigatingWithKeyboard) { + highlightElementInDOM(id); + } + }, + [isNavigatingWithKeyboard, highlightElementInDOM] + ); + + const handleMouseMove = useCallback(() => { + // We started using the mouse again. + // This will enable hover styles in individual rows. + setIsNavigatingWithKeyboard(false); + }, []); + + const handleMouseLeave = useCallback(() => { + bridge.send('clearHighlightedElementInDOM'); + }, [bridge]); + // Let react-window know to re-render any time the underlying tree data changes. // This includes the owner context, since it controls a filtered view of the tree. const itemData = useMemo( @@ -194,16 +247,22 @@ export default function Tree(props: Props) { baseDepth, numElements, getElementAtIndex, + isNavigatingWithKeyboard, + onElementMouseEnter: handleElementMouseEnter, lastScrolledIDRef, treeFocused, }), - [baseDepth, numElements, getElementAtIndex, lastScrolledIDRef, treeFocused] + [ + baseDepth, + numElements, + getElementAtIndex, + isNavigatingWithKeyboard, + handleElementMouseEnter, + lastScrolledIDRef, + treeFocused, + ] ); - const handleMouseLeave = useCallback(() => { - bridge.send('clearHighlightedElementInDOM'); - }, [bridge]); - return (
@@ -215,6 +274,7 @@ export default function Tree(props: Props) { onBlur={handleBlur} onFocus={handleFocus} onKeyPress={handleKeyPress} + onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} ref={focusTargetRef} tabIndex={0}