1- import React , { useState , useEffect } from "react" ;
1+ import React , { useState , useEffect , useRef , useLayoutEffect } from "react" ;
2+ import { createPortal } from "react-dom" ;
23import { cn } from "@/common/lib/utils" ;
34import type { SlashSuggestion } from "@/browser/utils/slashCommands/types" ;
45
@@ -13,6 +14,8 @@ interface CommandSuggestionsProps {
1314 isVisible : boolean ;
1415 ariaLabel ?: string ;
1516 listId ?: string ;
17+ /** Reference to the input element for portal positioning */
18+ anchorRef ?: React . RefObject < HTMLElement | null > ;
1619}
1720
1821// Main component
@@ -23,14 +26,51 @@ export const CommandSuggestions: React.FC<CommandSuggestionsProps> = ({
2326 isVisible,
2427 ariaLabel = "Command suggestions" ,
2528 listId,
29+ anchorRef,
2630} ) => {
2731 const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
32+ const [ position , setPosition ] = useState < { top : number ; left : number ; width : number } | null > (
33+ null
34+ ) ;
35+ const menuRef = useRef < HTMLDivElement > ( null ) ;
2836
2937 // Reset selection whenever suggestions change
3038 useEffect ( ( ) => {
3139 setSelectedIndex ( 0 ) ;
3240 } , [ suggestions ] ) ;
3341
42+ // Calculate position when using portal mode
43+ useLayoutEffect ( ( ) => {
44+ if ( ! anchorRef ?. current || ! isVisible ) {
45+ setPosition ( null ) ;
46+ return ;
47+ }
48+
49+ const updatePosition = ( ) => {
50+ const anchor = anchorRef . current ;
51+ if ( ! anchor ) return ;
52+
53+ const rect = anchor . getBoundingClientRect ( ) ;
54+ const menuHeight = menuRef . current ?. offsetHeight ?? 200 ;
55+
56+ setPosition ( {
57+ top : rect . top - menuHeight - 8 , // 8px gap above anchor
58+ left : rect . left ,
59+ width : rect . width ,
60+ } ) ;
61+ } ;
62+
63+ updatePosition ( ) ;
64+
65+ // Update on resize/scroll
66+ window . addEventListener ( "resize" , updatePosition ) ;
67+ window . addEventListener ( "scroll" , updatePosition , true ) ;
68+ return ( ) => {
69+ window . removeEventListener ( "resize" , updatePosition ) ;
70+ window . removeEventListener ( "scroll" , updatePosition , true ) ;
71+ } ;
72+ } , [ anchorRef , isVisible , suggestions ] ) ;
73+
3474 // Handle keyboard navigation
3575 useEffect ( ( ) => {
3676 if ( ! isVisible || suggestions . length === 0 ) return ;
@@ -84,16 +124,31 @@ export const CommandSuggestions: React.FC<CommandSuggestionsProps> = ({
84124 const activeSuggestion = suggestions [ selectedIndex ] ?? suggestions [ 0 ] ;
85125 const resolvedListId = listId ?? `command-suggestions-list` ;
86126
87- return (
127+ const content = (
88128 < div
129+ ref = { menuRef }
89130 id = { resolvedListId }
90131 role = "listbox"
91132 aria-label = { ariaLabel }
92133 aria-activedescendant = {
93134 activeSuggestion ? `${ resolvedListId } -option-${ activeSuggestion . id } ` : undefined
94135 }
95136 data-command-suggestions
96- className = "bg-separator border-border-light absolute right-0 bottom-full left-0 z-[100] mb-2 flex max-h-[200px] flex-col overflow-y-auto rounded border shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
137+ className = { cn (
138+ "bg-separator border-border-light z-[100] flex max-h-[200px] flex-col overflow-y-auto rounded border shadow-[0_-4px_12px_rgba(0,0,0,0.4)]" ,
139+ // Use absolute positioning relative to parent when not in portal mode
140+ ! anchorRef && "absolute right-0 bottom-full left-0 mb-2"
141+ ) }
142+ style = {
143+ anchorRef && position
144+ ? {
145+ position : "fixed" ,
146+ top : position . top ,
147+ left : position . left ,
148+ width : position . width ,
149+ }
150+ : undefined
151+ }
97152 >
98153 { suggestions . map ( ( suggestion , index ) => (
99154 < div
@@ -119,4 +174,11 @@ export const CommandSuggestions: React.FC<CommandSuggestionsProps> = ({
119174 </ div >
120175 </ div >
121176 ) ;
177+
178+ // Use portal when anchorRef is provided (to escape overflow:hidden containers)
179+ if ( anchorRef ) {
180+ return createPortal ( content , document . body ) ;
181+ }
182+
183+ return content ;
122184} ;
0 commit comments