diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css index 1e1544b477ca..3bd6a2519161 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css @@ -1,8 +1,13 @@ +.SuspenseBreadcrumbsContainer { + flex: 1; + display: flex; +} + .SuspenseBreadcrumbsList { margin: 0; padding: 0; list-style: none; - display: flex; + display: inline-flex; flex-direction: row; flex-wrap: nowrap; } @@ -34,3 +39,59 @@ .SuspenseBreadcrumbsButton:focus-visible { background: var(--color-button-background-focus); } + +.SuspenseBreadcrumbsMenuButton { + border-radius: 0.25rem; + display: inline-flex; + align-items: center; + padding: 0; + flex: 0 0 auto; + border: none; + background: var(--color-button-background); + color: var(--color-button); +} + +.SuspenseBreadcrumbsMenuButtonContent { + display: inline-flex; + align-items: center; + border-radius: 0.25rem; + padding: 0.25rem; +} + +.SuspenseBreadcrumbsMenuButton:hover { + color: var(--color-button-hover); +} +.SuspenseBreadcrumbsMenuButton[aria-expanded="true"], +.SuspenseBreadcrumbsMenuButton[aria-expanded="true"]:active { + color: var(--color-button-active); + outline: none; +} + +.SuspenseBreadcrumbsMenuButton:focus, +.SuspenseBreadcrumbsMenuButtonContent:focus { + outline: none; +} +.SuspenseBreadcrumbsMenuButton:focus > .SuspenseBreadcrumbsMenuButtonContent { + background: var(--color-button-background-focus); +} + +.SuspenseBreadcrumbsModal[data-reach-menu-list] { + display: inline-flex; + flex-direction: column; + background-color: var(--color-background); + color: var(--color-button); + padding: 0.25rem 0; + padding-right: 0; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + max-height: 10rem; + overflow: auto; + + /* Make sure this is above the DevTools, which are above the Overlay */ + z-index: 10000002; + position: relative; + + /* Reach UI tries to set its own :( */ + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index 2ad235d57778..53a10ed0dc22 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -11,37 +11,47 @@ import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types'; import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent'; import * as React from 'react'; -import {useContext} from 'react'; +import {useContext, useLayoutEffect, useRef, useState} from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import Tooltip from '../Components/reach-ui/tooltip'; +import { + Menu, + MenuList, + MenuButton, + MenuItem, +} from '../Components/reach-ui/menu-button'; import { TreeDispatcherContext, TreeStateContext, } from '../Components/TreeContext'; import {StoreContext} from '../context'; -import {useHighlightHostInstance} from '../hooks'; +import {useHighlightHostInstance, useIsOverflowing} from '../hooks'; import styles from './SuspenseBreadcrumbs.css'; import { SuspenseTreeStateContext, SuspenseTreeDispatcherContext, } from './SuspenseTreeContext'; -export default function SuspenseBreadcrumbs(): React$Node { +type SuspenseBreadcrumbsFlatListProps = { + onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, + onItemPointerEnter: ( + id: SuspenseNode['id'], + scrollIntoView?: boolean, + ) => void, + onItemPointerLeave: (event: SyntheticMouseEvent) => void, +}; + +function SuspenseBreadcrumbsFlatList({ + onItemClick, + onItemPointerEnter, + onItemPointerLeave, +}: SuspenseBreadcrumbsFlatListProps): React$Node { const store = useContext(StoreContext); const {activityID} = useContext(TreeStateContext); - const treeDispatch = useContext(TreeDispatcherContext); - const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {selectedSuspenseID, lineage, roots} = useContext( SuspenseTreeStateContext, ); - - const {highlightHostInstance, clearHighlightHostInstance} = - useHighlightHostInstance(); - - function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) { - event.preventDefault(); - treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); - suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id}); - } - return (
    {lineage === null ? null : lineage.length === 0 ? ( @@ -55,7 +65,7 @@ export default function SuspenseBreadcrumbs(): React$Node { aria-current="true"> @@ -88,3 +98,225 @@ export default function SuspenseBreadcrumbs(): React$Node {
); } + +type SuspenseBreadcrumbsMenuProps = { + onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, + onItemPointerEnter: ( + id: SuspenseNode['id'], + scrollIntoView?: boolean, + ) => void, + onItemPointerLeave: (event: SyntheticMouseEvent) => void, +}; + +function SuspenseBreadcrumbsMenu({ + onItemClick, + onItemPointerEnter, + onItemPointerLeave, +}: SuspenseBreadcrumbsMenuProps): React$Node { + const store = useContext(StoreContext); + const {activityID} = useContext(TreeStateContext); + const {selectedSuspenseID, lineage, roots} = useContext( + SuspenseTreeStateContext, + ); + const selectedSuspenseNode = + selectedSuspenseID !== null + ? store.getSuspenseByID(selectedSuspenseID) + : null; + + return ( + <> + {lineage === null ? null : lineage.length === 0 ? ( + // We selected the root. This means that we're currently viewing the Transition + // that rendered the whole screen. In laymans terms this is really "Initial Paint" . + // When we're looking at a subtree selection, then the equivalent is a + // "Transition" since in that case it's really about a Transition within the page. + roots.length > 0 ? ( + + ) : null + ) : ( + <> + + + {selectedSuspenseNode != null && ( + + )} + + )} + + ); +} + +type SuspenseBreadcrumbsDropdownProps = { + lineage: $ReadOnlyArray, + selectedIndex: number, + selectElement: (id: SuspenseNode['id']) => void, +}; +function SuspenseBreadcrumbsDropdown({ + lineage, + selectElement, +}: SuspenseBreadcrumbsDropdownProps) { + const store = useContext(StoreContext); + + const menuItems = []; + for (let index = lineage.length - 1; index >= 0; index--) { + const suspenseNodeID = lineage[index]; + const node = store.getSuspenseByID(suspenseNodeID); + menuItems.push( + + {node === null ? 'Unknown' : node.name || 'Unknown'} + , + ); + } + + return ( + + + + + + + + + + {menuItems} + + + ); +} + +type SuspenseBreadcrumbsToParentButtonProps = { + lineage: $ReadOnlyArray, + selectedSuspenseID: SuspenseNode['id'] | null, + selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, +}; +function SuspenseBreadcrumbsToParentButton({ + lineage, + selectedSuspenseID, + selectElement, +}: SuspenseBreadcrumbsToParentButtonProps) { + const store = useContext(StoreContext); + const selectedIndex = + selectedSuspenseID === null + ? lineage.length - 1 + : lineage.indexOf(selectedSuspenseID); + + if (selectedIndex <= 0) { + return null; + } + + const parentID = lineage[selectedIndex - 1]; + const parent = store.getSuspenseByID(parentID); + + return ( + + ); +} + +export default function SuspenseBreadcrumbs(): React$Node { + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); + + const {highlightHostInstance, clearHighlightHostInstance} = + useHighlightHostInstance(); + + function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) { + if (event !== undefined) { + // E.g. 3rd party component libraries might omit the event and already prevent default + // like Reach's MenuItem does. + event.preventDefault(); + } + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); + suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id}); + } + + const [elementsTotalWidth, setElementsTotalWidth] = useState(0); + const containerRef = useRef(null); + const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth); + + useLayoutEffect(() => { + const container = containerRef.current; + + if ( + container === null || + // We want to measure the size of the flat list only when it's being used. + isOverflowing + ) { + return; + } + + const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver; + const observer = new ResizeObserver(() => { + let totalWidth = 0; + for (let i = 0; i < container.children.length; i++) { + const element = container.children[i]; + const computedStyle = getComputedStyle(element); + + totalWidth += + element.offsetWidth + + parseInt(computedStyle.marginLeft, 10) + + parseInt(computedStyle.marginRight, 10); + } + setElementsTotalWidth(totalWidth); + }); + + observer.observe(container); + + return observer.disconnect.bind(observer); + }, [containerRef, isOverflowing]); + + return ( +
+ {isOverflowing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css index a7915d0d9101..92935bd4a6b4 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -121,15 +121,6 @@ border-bottom: 1px solid var(--color-border); } -.SuspenseBreadcrumbs { - flex: 1; - /** - * TODO: Switch to single item view on overflow like OwnerStack does. - * OwnerStack has more constraints that make it easier so it won't be a 1:1 port. - */ - overflow-x: auto; -} - .SuspenseTreeViewFooter { flex: 0 0 42px; display: flex; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 0b195a99c4db..3fdd9fe935a3 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -501,9 +501,7 @@ function SuspenseTab(_: {}) {
)} -
- -
+
{!hideSettings && }