@@ -7,6 +7,12 @@ export interface DisclosureMenuOptions {
77 triggerRef : React . RefObject < HTMLElement | null > ;
88 /** Ref to the container that wraps both the trigger and the menu popover. */
99 menuRef : React . RefObject < HTMLElement | null > ;
10+ /**
11+ * Optional ref to a portaled popover element rendered outside `menuRef`.
12+ * When provided, item querying and click-outside detection also consider
13+ * this element so that React-portaled menus work correctly.
14+ */
15+ popoverRef ?: React . RefObject < HTMLElement | null > ;
1016 isOpen : boolean ;
1117 onClose : ( ) => void ;
1218 /**
@@ -31,7 +37,7 @@ export interface DisclosureMenuOptions {
3137 * cleanup / close.
3238 */
3339export function useDisclosureMenu ( opts : DisclosureMenuOptions ) : void {
34- const { triggerRef, menuRef, isOpen, onClose, itemSelector = DEFAULT_ITEM_SELECTOR } = opts ;
40+ const { triggerRef, menuRef, popoverRef , isOpen, onClose, itemSelector = DEFAULT_ITEM_SELECTOR } = opts ;
3541
3642 // Keep a stable ref so callbacks inside the effect always see the latest onClose
3743 // without having to re-subscribe every render.
@@ -43,13 +49,14 @@ export function useDisclosureMenu(opts: DisclosureMenuOptions): void {
4349 if ( ! isOpen ) return ;
4450 // Defer one frame so the menu DOM is guaranteed to be in the tree.
4551 const frame = requestAnimationFrame ( ( ) => {
46- const items = menuRef . current ?. querySelectorAll < HTMLElement > (
52+ const root = popoverRef ?. current ?? menuRef . current ;
53+ const items = root ?. querySelectorAll < HTMLElement > (
4754 `${ itemSelector } :not([disabled]):not(:disabled)`
4855 ) ;
4956 items ?. [ 0 ] ?. focus ( ) ;
5057 } ) ;
5158 return ( ) => cancelAnimationFrame ( frame ) ;
52- } , [ isOpen , menuRef , itemSelector ] ) ;
59+ } , [ isOpen , menuRef , popoverRef , itemSelector ] ) ;
5360
5461 // Keyboard and click-outside handlers.
5562 useEffect ( ( ) => {
@@ -66,8 +73,9 @@ export function useDisclosureMenu(opts: DisclosureMenuOptions): void {
6673
6774 if ( e . key === "ArrowDown" || e . key === "ArrowUp" ) {
6875 e . preventDefault ( ) ;
76+ const root = popoverRef ?. current ?? menuRef . current ;
6977 const items = Array . from (
70- menuRef . current ?. querySelectorAll < HTMLElement > (
78+ root ?. querySelectorAll < HTMLElement > (
7179 `${ itemSelector } :not([disabled]):not(:disabled)`
7280 ) ?? [ ]
7381 ) ;
@@ -84,15 +92,16 @@ export function useDisclosureMenu(opts: DisclosureMenuOptions): void {
8492
8593 if ( e . key === "Enter" ) {
8694 const active = document . activeElement as HTMLElement | null ;
87- if ( active && menuRef . current ?. contains ( active ) ) {
95+ if ( active && ( menuRef . current ?. contains ( active ) || popoverRef ?. current ?. contains ( active ) ) ) {
8896 e . preventDefault ( ) ;
8997 active . click ( ) ;
9098 }
9199 }
92100 } ;
93101
94102 const onClick = ( e : MouseEvent ) => {
95- if ( ! menuRef . current ?. contains ( e . target as Node ) ) {
103+ const target = e . target as Node ;
104+ if ( ! menuRef . current ?. contains ( target ) && ! popoverRef ?. current ?. contains ( target ) ) {
96105 onCloseRef . current ( ) ;
97106 // No focus restoration on outside-click — the user clicked somewhere
98107 // intentionally and that element may already hold focus.
@@ -105,5 +114,5 @@ export function useDisclosureMenu(opts: DisclosureMenuOptions): void {
105114 document . removeEventListener ( "keydown" , onKey ) ;
106115 document . removeEventListener ( "click" , onClick ) ;
107116 } ;
108- } , [ isOpen , triggerRef , menuRef , itemSelector ] ) ;
117+ } , [ isOpen , triggerRef , menuRef , popoverRef , itemSelector ] ) ;
109118}
0 commit comments