1
1
import { AriaLabelingProps , DOMAttributes } from '@react-types/shared' ;
2
- import { focusWithoutScrolling , mergeProps } from '@react-aria/utils' ;
2
+ import { focusWithoutScrolling , mergeProps , useLayoutEffect } from '@react-aria/utils' ;
3
3
import { getInteractionModality , useFocusWithin , useHover } from '@react-aria/interactions' ;
4
4
// @ts -ignore
5
5
import intlMessages from '../intl/*.json' ;
@@ -29,14 +29,80 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
29
29
let stringFormatter = useLocalizedStringFormatter ( intlMessages , '@react-aria/toast' ) ;
30
30
let { landmarkProps} = useLandmark ( {
31
31
role : 'region' ,
32
- 'aria-label' : props [ 'aria-label' ] || stringFormatter . format ( 'notifications' )
32
+ 'aria-label' : props [ 'aria-label' ] || stringFormatter . format ( 'notifications' , { count : state . visibleToasts . length } )
33
33
} , ref ) ;
34
34
35
35
let { hoverProps} = useHover ( {
36
36
onHoverStart : state . pauseAll ,
37
37
onHoverEnd : state . resumeAll
38
38
} ) ;
39
39
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
+
40
106
let lastFocused = useRef ( null ) ;
41
107
let { focusWithinProps} = useFocusWithin ( {
42
108
onFocusWithin : ( e ) => {
@@ -49,10 +115,22 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
49
115
}
50
116
} ) ;
51
117
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
+
56
134
useEffect ( ( ) => {
57
135
return ( ) => {
58
136
if ( lastFocused . current && document . body . contains ( lastFocused . current ) ) {
@@ -61,6 +139,7 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
61
139
} else {
62
140
lastFocused . current . focus ( ) ;
63
141
}
142
+ lastFocused . current = null ;
64
143
}
65
144
} ;
66
145
} , [ ref ] ) ;
@@ -73,7 +152,16 @@ export function useToastRegion<T>(props: AriaToastRegionProps, state: ToastState
73
152
// - allows focus even outside a containing focus scope
74
153
// - doesn’t dismiss overlays when clicking on it, even though it is outside
75
154
// @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
+ }
77
165
} )
78
166
} ;
79
167
}
0 commit comments