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 {Fragment, 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, useIsOverflowing} from '../hooks';
import styles from './SuspenseBreadcrumbs.css';
import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
type SuspenseBreadcrumbsFlatListProps = {
onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
onItemPointerEnter: (
id: SuspenseNode['id'],
scrollIntoView?: boolean,
) => void,
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
setElementsTotalWidth: (width: number) => void,
};
function SuspenseBreadcrumbsFlatList({
onItemClick,
onItemPointerEnter,
onItemPointerLeave,
setElementsTotalWidth,
}: SuspenseBreadcrumbsFlatListProps): React$Node {
const store = useContext(StoreContext);
const {activityID} = useContext(TreeStateContext);
const {selectedSuspenseID, lineage, roots} = useContext(
SuspenseTreeStateContext,
);
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
const observer = new ResizeObserver(entries => {
const entry = entries[0];
setElementsTotalWidth(entry.contentRect.width);
});
observer.observe(container);
return observer.disconnect.bind(observer);
}, []);
return (
<ol className={styles.SuspenseBreadcrumbsList} ref={containerRef}>
{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 ? (
<li
className={styles.SuspenseBreadcrumbsListItem}
aria-current="true">
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
type="button">
{activityID === null ? 'Initial Paint' : 'Transition'}
</button>
</li>
) : null
) : (
lineage.map((id, index) => {
const node = store.getSuspenseByID(id);
return (
<Fragment key={id}>
<li
className={styles.SuspenseBreadcrumbsListItem}
aria-current={selectedSuspenseID === id}
onPointerEnter={onItemPointerEnter.bind(null, id, false)}
onPointerLeave={onItemPointerLeave}>
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(null, id)}
type="button">
{node === null ? 'Unknown' : node.name || 'Unknown'}
</button>
</li>
{index < lineage.length - 1 && (
<span className={styles.SuspenseBreadcrumbsListItemSeparator}>
ยป
</span>
)}
</Fragment>
);
})
)}
</ol>
);
}
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 ? (
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
type="button">
{activityID === null ? 'Initial Paint' : 'Transition'}
</button>
) : null
) : (
<>
<SuspenseBreadcrumbsDropdown
lineage={lineage}
selectElement={onItemClick}
/>
<SuspenseBreadcrumbsToParentButton
lineage={lineage}
selectedSuspenseID={selectedSuspenseID}
selectElement={onItemClick}
/>
{selectedSuspenseNode != null && (
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(null, selectedSuspenseNode.id)}
onPointerEnter={onItemPointerEnter.bind(
null,
selectedSuspenseNode.id,
false,
)}
onPointerLeave={onItemPointerLeave}
type="button">
{selectedSuspenseNode === null
? 'Unknown'
: selectedSuspenseNode.name || 'Unknown'}
</button>
)}
</>
)}
</>
);
}
type SuspenseBreadcrumbsDropdownProps = {
lineage: $ReadOnlyArray<SuspenseNode['id']>,
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(
<MenuItem
key={suspenseNodeID}
className={`${styles.Component}`}
onSelect={selectElement.bind(null, suspenseNodeID)}>
{node === null ? 'Unknown' : node.name || 'Unknown'}
</MenuItem>,
);
}
return (
<Menu>
<MenuButton className={styles.SuspenseBreadcrumbsMenuButton}>
<Tooltip label="Open elements dropdown">
<span
className={styles.SuspenseBreadcrumbsMenuButtonContent}
tabIndex={-1}>
<ButtonIcon type="more" />
</span>
</Tooltip>
</MenuButton>
<MenuList className={styles.SuspenseBreadcrumbsModal}>
{menuItems}
</MenuList>
</Menu>
);
}
type SuspenseBreadcrumbsToParentButtonProps = {
lineage: $ReadOnlyArray<SuspenseNode['id']>,
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 (
<Button
className={parent !== null ? undefined : styles.NotInStore}
onClick={parent !== null ? selectElement.bind(null, parentID) : null}
title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}>
<ButtonIcon type="previous" />
</Button>
);
}
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) {
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<HTMLDivElement | null>(null);
const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
return (
<div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
{isOverflowing ? (
<SuspenseBreadcrumbsMenu
onItemClick={handleClick}
onItemPointerEnter={highlightHostInstance}
onItemPointerLeave={clearHighlightHostInstance}
/>
) : (
<SuspenseBreadcrumbsFlatList
onItemClick={handleClick}
onItemPointerEnter={highlightHostInstance}
onItemPointerLeave={clearHighlightHostInstance}
setElementsTotalWidth={setElementsTotalWidth}
/>
)}
</div>
);
}