@@ -10,7 +10,6 @@ import styled, {keyframes} from 'styled-components'
10
10
import { get } from '../constants'
11
11
import { ConfirmationDialog } from '../Dialog/ConfirmationDialog'
12
12
import { useControllableState } from '../hooks/useControllableState'
13
- import useSafeTimeout from '../hooks/useSafeTimeout'
14
13
import { useId } from '../hooks/useId'
15
14
import Spinner from '../Spinner'
16
15
import sx , { SxProp } from '../sx'
@@ -510,17 +509,10 @@ export type TreeViewSubTreeProps = {
510
509
const SubTree : React . FC < TreeViewSubTreeProps > = ( { count, state, children} ) => {
511
510
const { announceUpdate} = React . useContext ( RootContext )
512
511
const { itemId, isExpanded, isSubTreeEmpty, setIsSubTreeEmpty} = React . useContext ( ItemContext )
513
- const [ isLoadingItemVisible , setIsLoadingItemVisible ] = React . useState ( false )
514
- const { safeSetTimeout} = useSafeTimeout ( )
515
512
const loadingItemRef = React . useRef < HTMLElement > ( null )
516
513
const ref = React . useRef < HTMLElement > ( null )
517
- const [ isPending , setPending ] = React . useState ( state === 'loading' )
518
-
519
- React . useEffect ( ( ) => {
520
- if ( state === 'loading' ) {
521
- setPending ( true )
522
- }
523
- } , [ state ] )
514
+ const [ loadingFocused , setLoadingFocused ] = React . useState ( false )
515
+ const previousState = usePreviousValue ( state )
524
516
525
517
React . useEffect ( ( ) => {
526
518
// If `state` is undefined, we're working in a synchronous context and need
@@ -536,61 +528,66 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
536
528
}
537
529
} , [ state , isSubTreeEmpty , setIsSubTreeEmpty , children ] )
538
530
539
- // If a consumer sets state="done" without having a previous state (like `loading`),
540
- // then it would announce on the first render. Using isPending is to only
541
- // announce being "loaded" when the state has changed from `loading` --> `done`.
531
+ // Handle transition from loading to done state
542
532
React . useEffect ( ( ) => {
543
- if ( isPending && state === 'done' ) {
544
- const parentItem = document . getElementById ( itemId )
533
+ if ( previousState === 'loading' && state === 'done' ) {
534
+ const parentElement = document . getElementById ( itemId )
535
+ if ( ! parentElement ) return
536
+
537
+ // Announce update to screen readers
538
+ const parentName = getAccessibleName ( parentElement )
545
539
546
- if ( ! parentItem ) return
540
+ if ( ref . current ?. childElementCount ) {
541
+ announceUpdate ( `${ parentName } content loaded` )
542
+ } else {
543
+ announceUpdate ( `${ parentName } is empty` )
544
+ }
547
545
548
- const { current : node } = ref
549
- const parentName = getAccessibleName ( parentItem )
546
+ // Move focus to the first child if the loading indicator
547
+ // was focused when the async items finished loading
548
+ if ( loadingFocused ) {
549
+ const firstChild = getFirstChildElement ( parentElement )
550
550
551
- safeSetTimeout ( ( ) => {
552
- if ( node && node . childElementCount > 0 ) {
553
- announceUpdate ( `${ parentName } content loaded` )
551
+ if ( firstChild ) {
552
+ firstChild . focus ( )
554
553
} else {
555
- announceUpdate ( ` ${ parentName } is empty` )
554
+ parentElement . focus ( )
556
555
}
557
- } )
558
556
559
- setPending ( false )
557
+ setLoadingFocused ( false )
558
+ }
560
559
}
561
- } , [ state , itemId , announceUpdate , safeSetTimeout , isPending ] )
560
+ } , [ loadingFocused , previousState , state , itemId , announceUpdate , ref ] )
562
561
563
- // Manage loading indicator state
562
+ // Track focus on the loading indicator
564
563
React . useEffect ( ( ) => {
565
- // If we're in the loading state, but not showing the loading indicator yet,
566
- // show the loading indicator
567
- if ( state === 'loading' && ! isLoadingItemVisible ) {
568
- setIsLoadingItemVisible ( true )
564
+ function handleFocus ( ) {
565
+ setLoadingFocused ( true )
569
566
}
570
567
571
- // If we're not in the loading state, but we're still showing a loading indicator,
572
- // hide the loading indicator and move focus if necessary
573
- if ( state !== 'loading' && isLoadingItemVisible ) {
574
- const isLoadingItemFocused = document . activeElement === loadingItemRef . current
568
+ function handleBlur ( event : FocusEvent ) {
569
+ // Skip blur events that are caused by the element being removed from the DOM.
570
+ // This can happen when the loading indicator is focused when async items are
571
+ // done loading and the loading indicator is removed from the DOM.
572
+ // If `loadingFocused` is `true` when `state` is `"done"` then the loading indicator
573
+ // was focused when the async items finished loading and we need to move focus to the
574
+ // first child.
575
+ if ( ! event . relatedTarget ) return
575
576
576
- setIsLoadingItemVisible ( false )
577
+ setLoadingFocused ( false )
578
+ }
577
579
578
- if ( isLoadingItemFocused ) {
579
- safeSetTimeout ( ( ) => {
580
- const parentElement = document . getElementById ( itemId )
581
- if ( ! parentElement ) return
580
+ const loadingElement = loadingItemRef . current
581
+ if ( ! loadingElement ) return
582
582
583
- const firstChild = getFirstChildElement ( parentElement )
583
+ loadingElement . addEventListener ( 'focus' , handleFocus )
584
+ loadingElement . addEventListener ( 'blur' , handleBlur )
584
585
585
- if ( firstChild ) {
586
- firstChild . focus ( )
587
- } else {
588
- parentElement . focus ( )
589
- }
590
- } )
591
- }
586
+ return ( ) => {
587
+ loadingElement . removeEventListener ( 'focus' , handleFocus )
588
+ loadingElement . removeEventListener ( 'blur' , handleBlur )
592
589
}
593
- } , [ state , safeSetTimeout , isLoadingItemVisible , itemId ] )
590
+ } , [ loadingItemRef , state ] )
594
591
595
592
if ( ! isExpanded ) {
596
593
return null
@@ -607,13 +604,23 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
607
604
// @ts -ignore Box doesn't have type support for `ref` used in combination with `as`
608
605
ref = { ref }
609
606
>
610
- { isLoadingItemVisible ? < LoadingItem ref = { loadingItemRef } count = { count } /> : children }
607
+ { state === 'loading' ? < LoadingItem ref = { loadingItemRef } count = { count } /> : children }
611
608
</ ul >
612
609
)
613
610
}
614
611
615
612
SubTree . displayName = 'TreeView.SubTree'
616
613
614
+ function usePreviousValue < T > ( value : T ) : T {
615
+ const ref = React . useRef ( value )
616
+
617
+ React . useEffect ( ( ) => {
618
+ ref . current = value
619
+ } , [ value ] )
620
+
621
+ return ref . current
622
+ }
623
+
617
624
const shimmer = keyframes `
618
625
from { mask-position: 200%; }
619
626
to { mask-position: 0%; }
0 commit comments