main
js 319 lines 10.2 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11 import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
12
13 import * as React from 'react';
14 import {Fragment, 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';
24 import {
25 TreeDispatcherContext,
26 TreeStateContext,
27 } from '../Components/TreeContext';
28 import {StoreContext} from '../context';
29 import {useHighlightHostInstance, useIsOverflowing} from '../hooks';
30 import styles from './SuspenseBreadcrumbs.css';
31 import {
32 SuspenseTreeStateContext,
33 SuspenseTreeDispatcherContext,
34 } from './SuspenseTreeContext';
35
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 setElementsTotalWidth: (width: number) => void,
44 };
45
46 function SuspenseBreadcrumbsFlatList({
47 onItemClick,
48 onItemPointerEnter,
49 onItemPointerLeave,
50 setElementsTotalWidth,
51 }: SuspenseBreadcrumbsFlatListProps): React$Node {
52 const store = useContext(StoreContext);
53 const {activityID} = useContext(TreeStateContext);
54 const {selectedSuspenseID, lineage, roots} = useContext(
55 SuspenseTreeStateContext,
56 );
57
58 const containerRef = useRef<HTMLDivElement | null>(null);
59 useLayoutEffect(() => {
60 const container = containerRef.current;
61 if (container === null) {
62 return;
63 }
64
65 const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
66 const observer = new ResizeObserver(entries => {
67 const entry = entries[0];
68 setElementsTotalWidth(entry.contentRect.width);
69 });
70
71 observer.observe(container);
72 return observer.disconnect.bind(observer);
73 }, []);
74
75 return (
76 <ol className={styles.SuspenseBreadcrumbsList} ref={containerRef}>
77 {lineage === null ? null : lineage.length === 0 ? (
78 // We selected the root. This means that we're currently viewing the Transition
79 // that rendered the whole screen. In laymans terms this is really "Initial Paint" .
80 // When we're looking at a subtree selection, then the equivalent is a
81 // "Transition" since in that case it's really about a Transition within the page.
82 roots.length > 0 ? (
83 <li
84 className={styles.SuspenseBreadcrumbsListItem}
85 aria-current="true">
86 <button
87 className={styles.SuspenseBreadcrumbsButton}
88 onClick={onItemClick.bind(
89 null,
90 activityID === null ? roots[0] : activityID,
91 )}
92 type="button">
93 {activityID === null ? 'Initial Paint' : 'Transition'}
94 </button>
95 </li>
96 ) : null
97 ) : (
98 lineage.map((id, index) => {
99 const node = store.getSuspenseByID(id);
100
101 return (
102 <Fragment key={id}>
103 <li
104 className={styles.SuspenseBreadcrumbsListItem}
105 aria-current={selectedSuspenseID === id}
106 onPointerEnter={onItemPointerEnter.bind(null, id, false)}
107 onPointerLeave={onItemPointerLeave}>
108 <button
109 className={styles.SuspenseBreadcrumbsButton}
110 onClick={onItemClick.bind(null, id)}
111 type="button">
112 {node === null ? 'Unknown' : node.name || 'Unknown'}
113 </button>
114 </li>
115 {index < lineage.length - 1 && (
116 <span className={styles.SuspenseBreadcrumbsListItemSeparator}>
117 »
118 </span>
119 )}
120 </Fragment>
121 );
122 })
123 )}
124 </ol>
125 );
126 }
127
128 type SuspenseBreadcrumbsMenuProps = {
129 onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
130 onItemPointerEnter: (
131 id: SuspenseNode['id'],
132 scrollIntoView?: boolean,
133 ) => void,
134 onItemPointerLeave: (event: SyntheticMouseEvent) => void,
135 };
136
137 function SuspenseBreadcrumbsMenu({
138 onItemClick,
139 onItemPointerEnter,
140 onItemPointerLeave,
141 }: SuspenseBreadcrumbsMenuProps): React$Node {
142 const store = useContext(StoreContext);
143 const {activityID} = useContext(TreeStateContext);
144 const {selectedSuspenseID, lineage, roots} = useContext(
145 SuspenseTreeStateContext,
146 );
147 const selectedSuspenseNode =
148 selectedSuspenseID !== null
149 ? store.getSuspenseByID(selectedSuspenseID)
150 : null;
151
152 return (
153 <>
154 {lineage === null ? null : lineage.length === 0 ? (
155 // We selected the root. This means that we're currently viewing the Transition
156 // that rendered the whole screen. In laymans terms this is really "Initial Paint" .
157 // When we're looking at a subtree selection, then the equivalent is a
158 // "Transition" since in that case it's really about a Transition within the page.
159 roots.length > 0 ? (
160 <button
161 className={styles.SuspenseBreadcrumbsButton}
162 onClick={onItemClick.bind(
163 null,
164 activityID === null ? roots[0] : activityID,
165 )}
166 type="button">
167 {activityID === null ? 'Initial Paint' : 'Transition'}
168 </button>
169 ) : null
170 ) : (
171 <>
172 <SuspenseBreadcrumbsDropdown
173 lineage={lineage}
174 selectElement={onItemClick}
175 />
176 <SuspenseBreadcrumbsToParentButton
177 lineage={lineage}
178 selectedSuspenseID={selectedSuspenseID}
179 selectElement={onItemClick}
180 />
181 {selectedSuspenseNode != null && (
182 <button
183 className={styles.SuspenseBreadcrumbsButton}
184 onClick={onItemClick.bind(null, selectedSuspenseNode.id)}
185 onPointerEnter={onItemPointerEnter.bind(
186 null,
187 selectedSuspenseNode.id,
188 false,
189 )}
190 onPointerLeave={onItemPointerLeave}
191 type="button">
192 {/* $FlowFixMe[invalid-compare] */}
193 {selectedSuspenseNode === null
194 ? 'Unknown'
195 : selectedSuspenseNode.name || 'Unknown'}
196 </button>
197 )}
198 </>
199 )}
200 </>
201 );
202 }
203
204 type SuspenseBreadcrumbsDropdownProps = {
205 lineage: $ReadOnlyArray<SuspenseNode['id']>,
206 selectedIndex: number,
207 selectElement: (id: SuspenseNode['id']) => void,
208 };
209 function SuspenseBreadcrumbsDropdown({
210 lineage,
211 selectElement,
212 }: SuspenseBreadcrumbsDropdownProps) {
213 const store = useContext(StoreContext);
214
215 const menuItems = [];
216 for (let index = lineage.length - 1; index >= 0; index--) {
217 const suspenseNodeID = lineage[index];
218 const node = store.getSuspenseByID(suspenseNodeID);
219 menuItems.push(
220 <MenuItem
221 key={suspenseNodeID}
222 className={`${styles.Component}`}
223 onSelect={selectElement.bind(null, suspenseNodeID)}>
224 {node === null ? 'Unknown' : node.name || 'Unknown'}
225 </MenuItem>,
226 );
227 }
228
229 return (
230 <Menu>
231 <MenuButton className={styles.SuspenseBreadcrumbsMenuButton}>
232 <Tooltip label="Open elements dropdown">
233 <span
234 className={styles.SuspenseBreadcrumbsMenuButtonContent}
235 tabIndex={-1}>
236 <ButtonIcon type="more" />
237 </span>
238 </Tooltip>
239 </MenuButton>
240 <MenuList className={styles.SuspenseBreadcrumbsModal}>
241 {menuItems}
242 </MenuList>
243 </Menu>
244 );
245 }
246
247 type SuspenseBreadcrumbsToParentButtonProps = {
248 lineage: $ReadOnlyArray<SuspenseNode['id']>,
249 selectedSuspenseID: SuspenseNode['id'] | null,
250 selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
251 };
252 function SuspenseBreadcrumbsToParentButton({
253 lineage,
254 selectedSuspenseID,
255 selectElement,
256 }: SuspenseBreadcrumbsToParentButtonProps) {
257 const store = useContext(StoreContext);
258 const selectedIndex =
259 selectedSuspenseID === null
260 ? lineage.length - 1
261 : lineage.indexOf(selectedSuspenseID);
262
263 if (selectedIndex <= 0) {
264 return null;
265 }
266
267 const parentID = lineage[selectedIndex - 1];
268 const parent = store.getSuspenseByID(parentID);
269
270 return (
271 <Button
272 className={parent !== null ? undefined : styles.NotInStore}
273 onClick={parent !== null ? selectElement.bind(null, parentID) : null}
274 title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}>
275 <ButtonIcon type="previous" />
276 </Button>
277 );
278 }
279
280 export default function SuspenseBreadcrumbs(): React$Node {
281 const treeDispatch = useContext(TreeDispatcherContext);
282 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
283
284 const {highlightHostInstance, clearHighlightHostInstance} =
285 useHighlightHostInstance();
286
287 function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) {
288 if (event !== undefined) {
289 // E.g. 3rd party component libraries might omit the event and already prevent default
290 // like Reach's MenuItem does.
291 event.preventDefault();
292 }
293 treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
294 suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
295 }
296
297 const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
298 const containerRef = useRef<HTMLDivElement | null>(null);
299 const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
300
301 return (
302 <div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
303 {isOverflowing ? (
304 <SuspenseBreadcrumbsMenu
305 onItemClick={handleClick}
306 onItemPointerEnter={highlightHostInstance}
307 onItemPointerLeave={clearHighlightHostInstance}
308 />
309 ) : (
310 <SuspenseBreadcrumbsFlatList
311 onItemClick={handleClick}
312 onItemPointerEnter={highlightHostInstance}
313 onItemPointerLeave={clearHighlightHostInstance}
314 setElementsTotalWidth={setElementsTotalWidth}
315 />
316 )}
317 </div>
318 );
319 }