Skip to content

Commit 8b276df

Browse files
authored
[DevTools] Avoid scrollbars in Suspense breadcrumbs (react#35700)
1 parent 95ffd6c commit 8b276df

4 files changed

Lines changed: 314 additions & 32 deletions

File tree

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
.SuspenseBreadcrumbsContainer {
2+
flex: 1;
3+
display: flex;
4+
}
5+
16
.SuspenseBreadcrumbsList {
27
margin: 0;
38
padding: 0;
49
list-style: none;
5-
display: flex;
10+
display: inline-flex;
611
flex-direction: row;
712
flex-wrap: nowrap;
813
}
@@ -34,3 +39,59 @@
3439
.SuspenseBreadcrumbsButton:focus-visible {
3540
background: var(--color-button-background-focus);
3641
}
42+
43+
.SuspenseBreadcrumbsMenuButton {
44+
border-radius: 0.25rem;
45+
display: inline-flex;
46+
align-items: center;
47+
padding: 0;
48+
flex: 0 0 auto;
49+
border: none;
50+
background: var(--color-button-background);
51+
color: var(--color-button);
52+
}
53+
54+
.SuspenseBreadcrumbsMenuButtonContent {
55+
display: inline-flex;
56+
align-items: center;
57+
border-radius: 0.25rem;
58+
padding: 0.25rem;
59+
}
60+
61+
.SuspenseBreadcrumbsMenuButton:hover {
62+
color: var(--color-button-hover);
63+
}
64+
.SuspenseBreadcrumbsMenuButton[aria-expanded="true"],
65+
.SuspenseBreadcrumbsMenuButton[aria-expanded="true"]:active {
66+
color: var(--color-button-active);
67+
outline: none;
68+
}
69+
70+
.SuspenseBreadcrumbsMenuButton:focus,
71+
.SuspenseBreadcrumbsMenuButtonContent:focus {
72+
outline: none;
73+
}
74+
.SuspenseBreadcrumbsMenuButton:focus > .SuspenseBreadcrumbsMenuButtonContent {
75+
background: var(--color-button-background-focus);
76+
}
77+
78+
.SuspenseBreadcrumbsModal[data-reach-menu-list] {
79+
display: inline-flex;
80+
flex-direction: column;
81+
background-color: var(--color-background);
82+
color: var(--color-button);
83+
padding: 0.25rem 0;
84+
padding-right: 0;
85+
border: 1px solid var(--color-border);
86+
border-radius: 0.25rem;
87+
max-height: 10rem;
88+
overflow: auto;
89+
90+
/* Make sure this is above the DevTools, which are above the Overlay */
91+
z-index: 10000002;
92+
position: relative;
93+
94+
/* Reach UI tries to set its own :( */
95+
font-family: var(--font-family-monospace);
96+
font-size: var(--font-size-monospace-normal);
97+
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js

Lines changed: 251 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,37 +11,47 @@ import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
1111
import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
1212

1313
import * as React from 'react';
14-
import {useContext} from 'react';
14+
import {useContext, useLayoutEffect, useRef, useState} from 'react';
15+
import Button from '../Button';
16+
import ButtonIcon from '../ButtonIcon';
17+
import Tooltip from '../Components/reach-ui/tooltip';
18+
import {
19+
Menu,
20+
MenuList,
21+
MenuButton,
22+
MenuItem,
23+
} from '../Components/reach-ui/menu-button';
1524
import {
1625
TreeDispatcherContext,
1726
TreeStateContext,
1827
} from '../Components/TreeContext';
1928
import {StoreContext} from '../context';
20-
import {useHighlightHostInstance} from '../hooks';
29+
import {useHighlightHostInstance, useIsOverflowing} from '../hooks';
2130
import styles from './SuspenseBreadcrumbs.css';
2231
import {
2332
SuspenseTreeStateContext,
2433
SuspenseTreeDispatcherContext,
2534
} from './SuspenseTreeContext';
2635

27-
export default function SuspenseBreadcrumbs(): React$Node {
36+
type SuspenseBreadcrumbsFlatListProps = {
37+
onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
38+
onItemPointerEnter: (
39+
id: SuspenseNode['id'],
40+
scrollIntoView?: boolean,
41+
) => void,
42+
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
43+
};
44+
45+
function SuspenseBreadcrumbsFlatList({
46+
onItemClick,
47+
onItemPointerEnter,
48+
onItemPointerLeave,
49+
}: SuspenseBreadcrumbsFlatListProps): React$Node {
2850
const store = useContext(StoreContext);
2951
const {activityID} = useContext(TreeStateContext);
30-
const treeDispatch = useContext(TreeDispatcherContext);
31-
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
3252
const {selectedSuspenseID, lineage, roots} = useContext(
3353
SuspenseTreeStateContext,
3454
);
35-
36-
const {highlightHostInstance, clearHighlightHostInstance} =
37-
useHighlightHostInstance();
38-
39-
function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) {
40-
event.preventDefault();
41-
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
42-
suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
43-
}
44-
4555
return (
4656
<ol className={styles.SuspenseBreadcrumbsList}>
4757
{lineage === null ? null : lineage.length === 0 ? (
@@ -55,7 +65,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
5565
aria-current="true">
5666
<button
5767
className={styles.SuspenseBreadcrumbsButton}
58-
onClick={handleClick.bind(
68+
onClick={onItemClick.bind(
5969
null,
6070
activityID === null ? roots[0] : activityID,
6171
)}
@@ -73,11 +83,11 @@ export default function SuspenseBreadcrumbs(): React$Node {
7383
key={id}
7484
className={styles.SuspenseBreadcrumbsListItem}
7585
aria-current={selectedSuspenseID === id}
76-
onPointerEnter={highlightHostInstance.bind(null, id, false)}
77-
onPointerLeave={clearHighlightHostInstance}>
86+
onPointerEnter={onItemPointerEnter.bind(null, id, false)}
87+
onPointerLeave={onItemPointerLeave}>
7888
<button
7989
className={styles.SuspenseBreadcrumbsButton}
80-
onClick={handleClick.bind(null, id)}
90+
onClick={onItemClick.bind(null, id)}
8191
type="button">
8292
{node === null ? 'Unknown' : node.name || 'Unknown'}
8393
</button>
@@ -88,3 +98,225 @@ export default function SuspenseBreadcrumbs(): React$Node {
8898
</ol>
8999
);
90100
}
101+
102+
type SuspenseBreadcrumbsMenuProps = {
103+
onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
104+
onItemPointerEnter: (
105+
id: SuspenseNode['id'],
106+
scrollIntoView?: boolean,
107+
) => void,
108+
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
109+
};
110+
111+
function SuspenseBreadcrumbsMenu({
112+
onItemClick,
113+
onItemPointerEnter,
114+
onItemPointerLeave,
115+
}: SuspenseBreadcrumbsMenuProps): React$Node {
116+
const store = useContext(StoreContext);
117+
const {activityID} = useContext(TreeStateContext);
118+
const {selectedSuspenseID, lineage, roots} = useContext(
119+
SuspenseTreeStateContext,
120+
);
121+
const selectedSuspenseNode =
122+
selectedSuspenseID !== null
123+
? store.getSuspenseByID(selectedSuspenseID)
124+
: null;
125+
126+
return (
127+
<>
128+
{lineage === null ? null : lineage.length === 0 ? (
129+
// We selected the root. This means that we're currently viewing the Transition
130+
// that rendered the whole screen. In laymans terms this is really "Initial Paint" .
131+
// When we're looking at a subtree selection, then the equivalent is a
132+
// "Transition" since in that case it's really about a Transition within the page.
133+
roots.length > 0 ? (
134+
<button
135+
className={styles.SuspenseBreadcrumbsButton}
136+
onClick={onItemClick.bind(
137+
null,
138+
activityID === null ? roots[0] : activityID,
139+
)}
140+
type="button">
141+
{activityID === null ? 'Initial Paint' : 'Transition'}
142+
</button>
143+
) : null
144+
) : (
145+
<>
146+
<SuspenseBreadcrumbsDropdown
147+
lineage={lineage}
148+
selectElement={onItemClick}
149+
/>
150+
<SuspenseBreadcrumbsToParentButton
151+
lineage={lineage}
152+
selectedSuspenseID={selectedSuspenseID}
153+
selectElement={onItemClick}
154+
/>
155+
{selectedSuspenseNode != null && (
156+
<button
157+
className={styles.SuspenseBreadcrumbsButton}
158+
onClick={onItemClick.bind(null, selectedSuspenseNode.id)}
159+
onPointerEnter={onItemPointerEnter.bind(
160+
null,
161+
selectedSuspenseNode.id,
162+
false,
163+
)}
164+
onPointerLeave={onItemPointerLeave}
165+
type="button">
166+
{selectedSuspenseNode === null
167+
? 'Unknown'
168+
: selectedSuspenseNode.name || 'Unknown'}
169+
</button>
170+
)}
171+
</>
172+
)}
173+
</>
174+
);
175+
}
176+
177+
type SuspenseBreadcrumbsDropdownProps = {
178+
lineage: $ReadOnlyArray<SuspenseNode['id']>,
179+
selectedIndex: number,
180+
selectElement: (id: SuspenseNode['id']) => void,
181+
};
182+
function SuspenseBreadcrumbsDropdown({
183+
lineage,
184+
selectElement,
185+
}: SuspenseBreadcrumbsDropdownProps) {
186+
const store = useContext(StoreContext);
187+
188+
const menuItems = [];
189+
for (let index = lineage.length - 1; index >= 0; index--) {
190+
const suspenseNodeID = lineage[index];
191+
const node = store.getSuspenseByID(suspenseNodeID);
192+
menuItems.push(
193+
<MenuItem
194+
key={suspenseNodeID}
195+
className={`${styles.Component}`}
196+
onSelect={selectElement.bind(null, suspenseNodeID)}>
197+
{node === null ? 'Unknown' : node.name || 'Unknown'}
198+
</MenuItem>,
199+
);
200+
}
201+
202+
return (
203+
<Menu>
204+
<MenuButton className={styles.SuspenseBreadcrumbsMenuButton}>
205+
<Tooltip label="Open elements dropdown">
206+
<span
207+
className={styles.SuspenseBreadcrumbsMenuButtonContent}
208+
tabIndex={-1}>
209+
<ButtonIcon type="more" />
210+
</span>
211+
</Tooltip>
212+
</MenuButton>
213+
<MenuList className={styles.SuspenseBreadcrumbsModal}>
214+
{menuItems}
215+
</MenuList>
216+
</Menu>
217+
);
218+
}
219+
220+
type SuspenseBreadcrumbsToParentButtonProps = {
221+
lineage: $ReadOnlyArray<SuspenseNode['id']>,
222+
selectedSuspenseID: SuspenseNode['id'] | null,
223+
selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
224+
};
225+
function SuspenseBreadcrumbsToParentButton({
226+
lineage,
227+
selectedSuspenseID,
228+
selectElement,
229+
}: SuspenseBreadcrumbsToParentButtonProps) {
230+
const store = useContext(StoreContext);
231+
const selectedIndex =
232+
selectedSuspenseID === null
233+
? lineage.length - 1
234+
: lineage.indexOf(selectedSuspenseID);
235+
236+
if (selectedIndex <= 0) {
237+
return null;
238+
}
239+
240+
const parentID = lineage[selectedIndex - 1];
241+
const parent = store.getSuspenseByID(parentID);
242+
243+
return (
244+
<Button
245+
className={parent !== null ? undefined : styles.NotInStore}
246+
onClick={parent !== null ? selectElement.bind(null, parentID) : null}
247+
title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}>
248+
<ButtonIcon type="previous" />
249+
</Button>
250+
);
251+
}
252+
253+
export default function SuspenseBreadcrumbs(): React$Node {
254+
const treeDispatch = useContext(TreeDispatcherContext);
255+
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
256+
257+
const {highlightHostInstance, clearHighlightHostInstance} =
258+
useHighlightHostInstance();
259+
260+
function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) {
261+
if (event !== undefined) {
262+
// E.g. 3rd party component libraries might omit the event and already prevent default
263+
// like Reach's MenuItem does.
264+
event.preventDefault();
265+
}
266+
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
267+
suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
268+
}
269+
270+
const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
271+
const containerRef = useRef<HTMLDivElement | null>(null);
272+
const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
273+
274+
useLayoutEffect(() => {
275+
const container = containerRef.current;
276+
277+
if (
278+
container === null ||
279+
// We want to measure the size of the flat list only when it's being used.
280+
isOverflowing
281+
) {
282+
return;
283+
}
284+
285+
const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
286+
const observer = new ResizeObserver(() => {
287+
let totalWidth = 0;
288+
for (let i = 0; i < container.children.length; i++) {
289+
const element = container.children[i];
290+
const computedStyle = getComputedStyle(element);
291+
292+
totalWidth +=
293+
element.offsetWidth +
294+
parseInt(computedStyle.marginLeft, 10) +
295+
parseInt(computedStyle.marginRight, 10);
296+
}
297+
setElementsTotalWidth(totalWidth);
298+
});
299+
300+
observer.observe(container);
301+
302+
return observer.disconnect.bind(observer);
303+
}, [containerRef, isOverflowing]);
304+
305+
return (
306+
<div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
307+
{isOverflowing ? (
308+
<SuspenseBreadcrumbsMenu
309+
onItemClick={handleClick}
310+
onItemPointerEnter={highlightHostInstance}
311+
onItemPointerLeave={clearHighlightHostInstance}
312+
/>
313+
) : (
314+
<SuspenseBreadcrumbsFlatList
315+
onItemClick={handleClick}
316+
onItemPointerEnter={highlightHostInstance}
317+
onItemPointerLeave={clearHighlightHostInstance}
318+
/>
319+
)}
320+
</div>
321+
);
322+
}

0 commit comments

Comments
 (0)