Skip to content

Commit d538e85

Browse files
committed
[DevTools] Avoid scrollbars in Suspense breadcrumbs
1 parent 479359d commit d538e85

File tree

2 files changed

+238
-17
lines changed

2 files changed

+238
-17
lines changed

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

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
.SuspenseBreadcrumbsContainer {
22
flex: 1;
3-
/**
4-
* TODO: Switch to single item view on overflow like OwnerStack does.
5-
* OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
6-
*/
7-
overflow-x: auto;
3+
display: flex;
84
}
95

106
.SuspenseBreadcrumbsList {
117
margin: 0;
128
padding: 0;
139
list-style: none;
14-
display: flex;
10+
display: inline-flex;
1511
flex-direction: row;
1612
flex-wrap: nowrap;
1713
}
@@ -43,3 +39,59 @@
4339
.SuspenseBreadcrumbsButton:focus-visible {
4440
background: var(--color-button-background-focus);
4541
}
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: 180 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,16 @@ 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, useLayoutEffect, useState} 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,
@@ -99,8 +108,146 @@ type SuspenseBreadcrumbsMenuProps = {
99108
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
100109
};
101110

102-
function SuspenseBreadcrumbsMenu({}: SuspenseBreadcrumbsMenuProps): React$Node {
103-
return <div>TODO: Suspense Breadcrumbs Menu</div>;
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+
);
104251
}
105252

106253
export default function SuspenseBreadcrumbs(): React$Node {
@@ -110,26 +257,44 @@ export default function SuspenseBreadcrumbs(): React$Node {
110257
const {highlightHostInstance, clearHighlightHostInstance} =
111258
useHighlightHostInstance();
112259

113-
function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) {
114-
event.preventDefault();
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+
}
115266
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
116267
suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
117268
}
118269

119270
const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
120-
const containerRef = React.useRef<HTMLDivElement | null>(null);
271+
const containerRef = useRef<HTMLDivElement | null>(null);
121272
const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
122273

123274
useLayoutEffect(() => {
124275
const container = containerRef.current;
125-
if (container === null) {
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+
) {
126282
return;
127283
}
128284

129285
const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
130-
const observer = new ResizeObserver(entries => {
131-
const entry = entries[0];
132-
setElementsTotalWidth(entry.contentRect.width);
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);
133298
});
134299

135300
observer.observe(container);
@@ -140,7 +305,11 @@ export default function SuspenseBreadcrumbs(): React$Node {
140305
return (
141306
<div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
142307
{isOverflowing ? (
143-
<SuspenseBreadcrumbsMenu />
308+
<SuspenseBreadcrumbsMenu
309+
onItemClick={handleClick}
310+
onItemPointerEnter={highlightHostInstance}
311+
onItemPointerLeave={clearHighlightHostInstance}
312+
/>
144313
) : (
145314
<SuspenseBreadcrumbsFlatList
146315
onItemClick={handleClick}

0 commit comments

Comments
 (0)