1- import { useEffect , useId , useRef , useState , type ReactNode } from "react" ;
1+ import { useEffect , useId , useLayoutEffect , useRef , useState , type ReactNode } from "react" ;
22
33import { cn } from "~/lib/utils" ;
44import { ComposerBanner , type ComposerBannerVariant } from "./ComposerBanner" ;
@@ -46,6 +46,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
4646 const [ stackExpanded , setStackExpanded ] = useState ( false ) ;
4747 const noticesRef = useRef < HTMLDivElement > ( null ) ;
4848 const peekRef = useRef < HTMLButtonElement > ( null ) ;
49+ const expandedItemsRef = useRef < HTMLDivElement > ( null ) ;
50+ const pendingFocusRef = useRef < "peek" | "notice" | null > ( null ) ;
4951 const expandedItemsId = useId ( ) ;
5052 const [ requestedExitingItemId , setExitingItemId ] = useState < string | null > ( null ) ;
5153 const dismissTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
@@ -66,6 +68,19 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
6668 if ( items . length < 2 ) setStackExpanded ( false ) ;
6769 } , [ items . length ] ) ;
6870
71+ useLayoutEffect ( ( ) => {
72+ if ( stackExpanded && pendingFocusRef . current === "notice" ) {
73+ pendingFocusRef . current = null ;
74+ const firstControl = expandedItemsRef . current ?. querySelector < HTMLElement > (
75+ 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]' ,
76+ ) ;
77+ ( firstControl ?? expandedItemsRef . current ) ?. focus ( { preventScroll : true } ) ;
78+ } else if ( ! stackExpanded && pendingFocusRef . current === "peek" ) {
79+ pendingFocusRef . current = null ;
80+ peekRef . current ?. focus ( { preventScroll : true } ) ;
81+ }
82+ } , [ stackExpanded ] ) ;
83+
6984 if ( items . length === 0 ) {
7085 return null ;
7186 }
@@ -130,14 +145,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130145 { hasStack ? (
131146 < div
132147 ref = { noticesRef }
133- className = "relative z-20"
148+ className = { cn ( "relative z-20" , stackExpanded && "min-h-3" ) }
134149 onPointerEnter = { ( event ) => {
135- if ( event . pointerType !== "touch" ) setStackExpanded ( true ) ;
150+ if ( event . pointerType === "touch" ) return ;
151+ if ( document . activeElement === peekRef . current ) {
152+ pendingFocusRef . current = "notice" ;
153+ }
154+ setStackExpanded ( true ) ;
136155 } }
137156 onPointerLeave = { ( event ) => {
138157 if ( ! event . currentTarget . contains ( document . activeElement ) ) setStackExpanded ( false ) ;
139158 } }
140- onFocusCapture = { ( ) => setStackExpanded ( true ) }
141159 onBlurCapture = { ( event ) => {
142160 if (
143161 ! event . currentTarget . contains ( event . relatedTarget ) &&
@@ -147,9 +165,10 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
147165 }
148166 } }
149167 onKeyDown = { ( event ) => {
150- if ( event . key !== "Escape" ) return ;
168+ if ( event . key !== "Escape" || ! stackExpanded ) return ;
169+ event . preventDefault ( ) ;
151170 event . stopPropagation ( ) ;
152- peekRef . current ?. focus ( { preventScroll : true } ) ;
171+ pendingFocusRef . current = "peek" ;
153172 setStackExpanded ( false ) ;
154173 } }
155174 >
@@ -160,18 +179,25 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
160179 aria-label = "Show other notices"
161180 aria-expanded = { stackExpanded }
162181 aria-controls = { expandedItemsId }
182+ aria-hidden = { stackExpanded || undefined }
183+ tabIndex = { stackExpanded ? - 1 : 0 }
163184 onClick = { ( event ) => {
164185 event . currentTarget . focus ( { preventScroll : true } ) ;
186+ pendingFocusRef . current = "notice" ;
165187 setStackExpanded ( true ) ;
166188 } }
167- className = { cn ( stackExpanded && "opacity-0" ) }
189+ className = { cn ( stackExpanded && "pointer-events-none invisible opacity-0" ) }
168190 />
169191 ) : null }
170192 < div
171193 id = { expandedItemsId }
194+ ref = { expandedItemsRef }
195+ role = "group"
196+ aria-label = "Other notices"
197+ tabIndex = { - 1 }
172198 data-composer-banner-stack-expanded-items = "true"
173199 className = { cn (
174- "grid transition-[grid-template-rows] duration-150 ease-out" ,
200+ "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring " ,
175201 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]" ,
176202 ) }
177203 >
0 commit comments