1- import { useEffect , useRef , useState } from 'react' ;
1+ import { useCallback , useEffect , useRef , useState } from 'react' ;
22
33import { FloatingTip } from '../shared/FloatingTip' ;
44import { InlineTip } from '../shared/InlineTip' ;
@@ -29,27 +29,34 @@ export const InfoTip: React.FC<InfoTipProps> = ({
2929} ) => {
3030 const [ isTipHidden , setHideTip ] = useState ( true ) ;
3131 const [ isAriaHidden , setIsAriaHidden ] = useState ( false ) ;
32+ const [ shouldAnnounce , setShouldAnnounce ] = useState ( false ) ;
3233 const wrapperRef = useRef < HTMLDivElement > ( null ) ;
34+ const buttonRef = useRef < HTMLButtonElement > ( null ) ;
35+ const popoverContentRef = useRef < HTMLDivElement > ( null ) ;
3336 const [ loaded , setLoaded ] = useState ( false ) ;
3437
3538 useEffect ( ( ) => {
3639 setLoaded ( true ) ;
3740 } , [ ] ) ;
3841
39- const setTipIsHidden = ( nextTipState : boolean ) => {
40- if ( ! nextTipState ) {
41- setHideTip ( nextTipState ) ;
42- if ( placement !== 'floating' ) {
43- // on inline component - stops text from being able to be navigated through, instead user can nav through visible text
44- setTimeout ( ( ) => {
45- setIsAriaHidden ( true ) ;
46- } , 1000 ) ;
42+ const setTipIsHidden = useCallback (
43+ ( nextTipState : boolean ) => {
44+ if ( ! nextTipState ) {
45+ setHideTip ( nextTipState ) ;
46+ if ( placement !== 'floating' ) {
47+ // on inline component - stops text from being able to be navigated through, instead user can nav through visible text
48+ setTimeout ( ( ) => {
49+ setIsAriaHidden ( true ) ;
50+ } , 1000 ) ;
51+ }
52+ } else {
53+ if ( isAriaHidden ) setIsAriaHidden ( false ) ;
54+ setHideTip ( nextTipState ) ;
55+ setShouldAnnounce ( false ) ;
4756 }
48- } else {
49- if ( isAriaHidden ) setIsAriaHidden ( false ) ;
50- setHideTip ( nextTipState ) ;
51- }
52- } ;
57+ } ,
58+ [ isAriaHidden , placement ]
59+ ) ;
5360
5461 const escapeKeyPressHandler = (
5562 event : React . KeyboardEvent < HTMLDivElement >
@@ -73,6 +80,12 @@ export const InfoTip: React.FC<InfoTipProps> = ({
7380 const clickHandler = ( ) => {
7481 const currentTipState = ! isTipHidden ;
7582 setTipIsHidden ( currentTipState ) ;
83+ if ( ! currentTipState ) {
84+ // Delay slightly to ensure focus has settled back on button before announcing
85+ setTimeout ( ( ) => {
86+ setShouldAnnounce ( true ) ;
87+ } , 0 ) ;
88+ }
7689 // we want to call the onClick handler after the tip has mounted
7790 if ( onClick ) setTimeout ( ( ) => onClick ( { isTipHidden : currentTipState } ) , 0 ) ;
7891 } ;
@@ -84,6 +97,59 @@ export const InfoTip: React.FC<InfoTipProps> = ({
8497 } ;
8598 } ) ;
8699
100+ useEffect ( ( ) => {
101+ if ( ! isTipHidden && placement === 'floating' ) {
102+ const handleGlobalEscapeKey = ( e : KeyboardEvent ) => {
103+ if ( e . key === 'Escape' ) {
104+ setTipIsHidden ( true ) ;
105+ buttonRef . current ?. focus ( ) ;
106+ }
107+ } ;
108+
109+ const handleFocusOut = ( event : FocusEvent ) => {
110+ const popoverContent = popoverContentRef . current ;
111+ const button = buttonRef . current ;
112+ const wrapper = wrapperRef . current ;
113+
114+ const { relatedTarget } = event ;
115+
116+ if ( relatedTarget instanceof Node ) {
117+ // If focus is moving back to the button or wrapper, allow it
118+ const movingToButton =
119+ button ?. contains ( relatedTarget ) || wrapper ?. contains ( relatedTarget ) ;
120+ if ( movingToButton ) return ;
121+
122+ // If focus is staying within the popover content, allow it
123+ if ( popoverContent ?. contains ( relatedTarget ) ) return ;
124+ }
125+
126+ // Return focus to button to maintain logical tab order
127+ setTimeout ( ( ) => {
128+ buttonRef . current ?. focus ( ) ;
129+ } , 0 ) ;
130+ } ;
131+
132+ // Wait for the popover ref to be set before attaching the listener
133+ let popoverContent : HTMLDivElement | null = null ;
134+ const timeoutId = setTimeout ( ( ) => {
135+ popoverContent = popoverContentRef . current ;
136+ if ( popoverContent ) {
137+ popoverContent . addEventListener ( 'focusout' , handleFocusOut ) ;
138+ }
139+ } , 0 ) ;
140+
141+ document . addEventListener ( 'keydown' , handleGlobalEscapeKey ) ;
142+
143+ return ( ) => {
144+ clearTimeout ( timeoutId ) ;
145+ if ( popoverContent ) {
146+ popoverContent . removeEventListener ( 'focusout' , handleFocusOut ) ;
147+ }
148+ document . removeEventListener ( 'keydown' , handleGlobalEscapeKey ) ;
149+ } ;
150+ }
151+ } , [ isTipHidden , placement , setTipIsHidden ] ) ;
152+
87153 const isFloating = placement === 'floating' ;
88154
89155 const Tip = loaded && isFloating ? FloatingTip : InlineTip ;
@@ -93,6 +159,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
93159 escapeKeyPressHandler,
94160 info,
95161 isTipHidden,
162+ popoverContentRef,
96163 wrapperRef,
97164 ...rest ,
98165 } ;
@@ -103,7 +170,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
103170 aria-live = "assertive"
104171 screenreader
105172 >
106- { ! isTipHidden ? info : `\xa0` }
173+ { shouldAnnounce && ! isTipHidden ? info : `\xa0` }
107174 </ ScreenreaderNavigableText >
108175 ) ;
109176
@@ -112,6 +179,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
112179 active = { ! isTipHidden }
113180 aria-expanded = { ! isTipHidden }
114181 emphasis = { emphasis }
182+ ref = { buttonRef }
115183 onClick = { ( ) => clickHandler ( ) }
116184 />
117185 ) ;
0 commit comments