@@ -11,37 +11,47 @@ import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
1111import typeof { SyntheticMouseEvent } from 'react-dom-bindings/src/events/SyntheticEvent' ;
1212
1313import * 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' ;
1524import {
1625 TreeDispatcherContext ,
1726 TreeStateContext ,
1827} from '../Components/TreeContext' ;
1928import { StoreContext } from '../context' ;
20- import { useHighlightHostInstance } from '../hooks' ;
29+ import { useHighlightHostInstance , useIsOverflowing } from '../hooks' ;
2130import styles from './SuspenseBreadcrumbs.css' ;
2231import {
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