11import { AriaLabelingProps , DOMAttributes } from '@react-types/shared' ;
2- import { focusWithoutScrolling , mergeProps } from '@react-aria/utils' ;
2+ import { focusWithoutScrolling , mergeProps , useLayoutEffect } from '@react-aria/utils' ;
33import { getInteractionModality , useFocusWithin , useHover } from '@react-aria/interactions' ;
44// @ts -ignore
55import intlMessages from '../intl/*.json' ;
@@ -29,14 +29,80 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
2929 let stringFormatter = useLocalizedStringFormatter ( intlMessages , '@react-aria/toast' ) ;
3030 let { landmarkProps} = useLandmark ( {
3131 role : 'region' ,
32- 'aria-label' : props [ 'aria-label' ] || stringFormatter . format ( 'notifications' )
32+ 'aria-label' : props [ 'aria-label' ] || stringFormatter . format ( 'notifications' , { count : state . visibleToasts . length } )
3333 } , ref ) ;
3434
3535 let { hoverProps} = useHover ( {
3636 onHoverStart : state . pauseAll ,
3737 onHoverEnd : state . resumeAll
3838 } ) ;
3939
40+ // Manage focus within the toast region.
41+ // If a focused containing toast is removed, move focus to the next toast, or the previous toast if there is no next toast.
42+ // We might be making an assumption with how this works if someone implements the priority queue differently, or
43+ // if they only show one toast at a time.
44+ let toasts = useRef ( [ ] ) ;
45+ let prevVisibleToasts = useRef ( state . visibleToasts ) ;
46+ let focusedToast = useRef ( null ) ;
47+ useLayoutEffect ( ( ) => {
48+ // If no toast has focus, then don't do anything.
49+ if ( focusedToast . current === - 1 || state . visibleToasts . length === 0 ) {
50+ toasts . current = [ ] ;
51+ prevVisibleToasts . current = state . visibleToasts ;
52+ return ;
53+ }
54+ toasts . current = [ ...ref . current . querySelectorAll ( '[role="alertdialog"]' ) ] ;
55+ // If the visible toasts haven't changed, we don't need to do anything.
56+ if ( prevVisibleToasts . current . length === state . visibleToasts . length
57+ && state . visibleToasts . every ( ( t , i ) => t . key === prevVisibleToasts . current [ i ] . key ) ) {
58+ prevVisibleToasts . current = state . visibleToasts ;
59+ return ;
60+ }
61+ // Get a list of all toasts by index and add info if they are removed.
62+ let allToasts = prevVisibleToasts . current
63+ . map ( ( t , i ) => ( {
64+ ...t ,
65+ i,
66+ isRemoved : ! state . visibleToasts . some ( t2 => t . key === t2 . key )
67+ } ) ) ;
68+
69+ let removedFocusedToastIndex = allToasts . findIndex ( t => t . i === focusedToast . current ) ;
70+
71+ // If the focused toast was removed, focus the next or previous toast.
72+ if ( removedFocusedToastIndex > - 1 ) {
73+ let i = 0 ;
74+ let nextToast ;
75+ let prevToast ;
76+ while ( i <= removedFocusedToastIndex ) {
77+ if ( ! allToasts [ i ] . isRemoved ) {
78+ prevToast = Math . max ( 0 , i - 1 ) ;
79+ }
80+ i ++ ;
81+ }
82+ while ( i < allToasts . length ) {
83+ if ( ! allToasts [ i ] . isRemoved ) {
84+ nextToast = i - 1 ;
85+ break ;
86+ }
87+ i ++ ;
88+ }
89+
90+ // in the case where it's one toast at a time, both will be undefined, but we know the index must be 0
91+ if ( prevToast === undefined && nextToast === undefined ) {
92+ prevToast = 0 ;
93+ }
94+
95+ // prioritize going to newer toasts
96+ if ( prevToast >= 0 && prevToast < toasts . current . length ) {
97+ focusWithoutScrolling ( toasts . current [ prevToast ] ) ;
98+ } else if ( nextToast >= 0 && nextToast < toasts . current . length ) {
99+ focusWithoutScrolling ( toasts . current [ nextToast ] ) ;
100+ }
101+ }
102+
103+ prevVisibleToasts . current = state . visibleToasts ;
104+ } , [ state . visibleToasts , ref ] ) ;
105+
40106 let lastFocused = useRef ( null ) ;
41107 let { focusWithinProps} = useFocusWithin ( {
42108 onFocusWithin : ( e ) => {
@@ -49,10 +115,22 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
49115 }
50116 } ) ;
51117
52- // When the region unmounts, restore focus to the last element that had focus
53- // before the user moved focus into the region.
54- // TODO: handle when the element has unmounted like FocusScope does?
55- // eslint-disable-next-line arrow-body-style
118+ // When the number of visible toasts becomes 0 or the region unmounts,
119+ // restore focus to the last element that had focus before the user moved focus
120+ // into the region. FocusScope restore focus doesn't update whenever the focus
121+ // moves in, it only happens once, so we correct it.
122+ // Because we're in a hook, we can't control if the user unmounts or not.
123+ useEffect ( ( ) => {
124+ if ( state . visibleToasts . length === 0 && lastFocused . current && document . body . contains ( lastFocused . current ) ) {
125+ if ( getInteractionModality ( ) === 'pointer' ) {
126+ focusWithoutScrolling ( lastFocused . current ) ;
127+ } else {
128+ lastFocused . current . focus ( ) ;
129+ }
130+ lastFocused . current = null ;
131+ }
132+ } , [ ref , state . visibleToasts . length ] ) ;
133+
56134 useEffect ( ( ) => {
57135 return ( ) => {
58136 if ( lastFocused . current && document . body . contains ( lastFocused . current ) ) {
@@ -61,6 +139,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
61139 } else {
62140 lastFocused . current . focus ( ) ;
63141 }
142+ lastFocused . current = null ;
64143 }
65144 } ;
66145 } , [ ref ] ) ;
@@ -73,7 +152,16 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
73152 // - allows focus even outside a containing focus scope
74153 // - doesn’t dismiss overlays when clicking on it, even though it is outside
75154 // @ts -ignore
76- 'data-react-aria-top-layer' : true
155+ 'data-react-aria-top-layer' : true ,
156+ // listen to focus events separate from focuswithin because that will only fire once
157+ // and we need to follow all focus changes
158+ onFocus : ( e ) => {
159+ let target = e . target . closest ( '[role="alertdialog"]' ) ;
160+ focusedToast . current = toasts . current . findIndex ( t => t === target ) ;
161+ } ,
162+ onBlur : ( ) => {
163+ focusedToast . current = - 1 ;
164+ }
77165 } )
78166 } ;
79167}
0 commit comments