diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index 9ab7d5dcb..b91eb614b 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils/cn'; import { useClickOutside } from '../../hooks/useClickOutside'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; import { inputVariants } from '../Input'; export interface AutocompleteProps extends Pick< @@ -120,15 +122,7 @@ function Autocomplete({ const containerRef = React.useRef(null); const listId = React.useId(); - useClickOutside(containerRef, () => setOpen(false), open); - - const setQuery = React.useCallback( - (next: string) => { - if (!isControlled) setUncontrolledValue(next); - onValueChange?.(next); - }, - [isControlled, onValueChange] - ); + const meetsMinLength = query.length >= minQueryLength; const filteredItems = React.useMemo(() => { if (!filter) return items; @@ -149,11 +143,31 @@ function Autocomplete({ return itemRows; }, [filteredItems, showCreate, getItemKey]); - const meetsMinLength = query.length >= minQueryLength; const hasContent = rows.length > 0 || (meetsMinLength && emptyMessage != null); const isOpen = open && meetsMinLength && hasContent; + // Portal + fixed positioning so the listbox escapes overflow-hidden + // ancestors (cards, dialogs, scroll containers, …). + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ open: isOpen, matchWidth: true, maxHeight: 300 }); + + const outsideRefs = React.useMemo( + () => [containerRef, floatingRef], + [floatingRef] + ); + useClickOutside(outsideRefs, () => setOpen(false), open); + + const setQuery = React.useCallback( + (next: string) => { + if (!isControlled) setUncontrolledValue(next); + onValueChange?.(next); + }, + [isControlled, onValueChange] + ); + React.useEffect(() => { if (!isOpen) setActiveIndex(-1); }, [isOpen]); @@ -207,7 +221,10 @@ function Autocomplete({ return (
{ + containerRef.current = node; + anchorRef.current = node; + }} data-slot="autocomplete" className={cn('relative', className)} > @@ -234,60 +251,66 @@ function Autocomplete({ {...inputProps} /> - {isOpen && ( -
- {rows.length === 0 - ? emptyMessage != null && ( -
- {emptyMessage} -
- ) - : rows.map((row, index) => { - const isActive = index === activeIndex; - const optionId = `${listId}-opt-${row.key}`; - return ( - - ); - })} -
- )} + {emptyMessage} +
+ ) + : rows.map((row, index) => { + const isActive = index === activeIndex; + const optionId = `${listId}-opt-${row.key}`; + return ( + + ); + })} + , + document.body + )} ); } diff --git a/src/components/BookingDialog/BookingDialog.tsx b/src/components/BookingDialog/BookingDialog.tsx index a6482238e..18eca8c58 100644 --- a/src/components/BookingDialog/BookingDialog.tsx +++ b/src/components/BookingDialog/BookingDialog.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils/cn'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; +import { useClickOutside } from '../../hooks/useClickOutside'; import { isStorybookDocsMode } from '../../utils/environment'; // ============================================================================= @@ -151,18 +154,18 @@ export function ServiceSelect({ const [isOpen, setIsOpen] = React.useState(false); const dropdownRef = React.useRef(null); - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + // Portal + fixed positioning so the dropdown escapes overflow-hidden + // ancestors (the dialog itself scrolls/clips). + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ open: isOpen, matchWidth: true, maxHeight: 240 }); + + const outsideRefs = React.useMemo( + () => [dropdownRef, floatingRef], + [floatingRef] + ); + useClickOutside(outsideRefs, () => setIsOpen(false), isOpen); const toggleService = (serviceSlug: string) => { if (selectedServices.includes(serviceSlug)) { @@ -178,7 +181,10 @@ export function ServiceSelect({ return (
{ + dropdownRef.current = node; + anchorRef.current = node; + }} className={cn('relative', className)} data-slot="service-select" > @@ -218,33 +224,37 @@ export function ServiceSelect({ /> - {isOpen && ( -
- {services.map((service) => ( - - ))} - {services.length === 0 && ( -
- No services available -
- )} -
- )} + {isOpen && + createPortal( +
+ {services.map((service) => ( + + ))} + {services.length === 0 && ( +
+ No services available +
+ )} +
, + document.body + )} {error && (

{hours.length > 0 && ( -
- -
- - -
-
+ + Copy + + } + > + handleCopyToAll(dayIndex)} + disabled={disabled} + > + Copy to all days + + handleCopyToWeekdays(dayIndex)} + disabled={disabled} + > + Copy to weekdays + + )} - - {drill.parent.label} - - - — {DETAIL_NOUN[drill.parent.domain] ?? 'related codes'} - {drill.results === null ? '…' : ` (${drill.results.length})`} - -
- )} - -
    e.preventDefault()} > - {list.map((r, i) => ( -
  • + {drill && ( +
    - {!drill && isDrillable(r) && ( + + {drill.parent.label} + + + — {DETAIL_NOUN[drill.parent.domain] ?? 'related codes'} + {drill.results === null + ? '…' + : ` (${drill.results.length})`} + +
    + )} + +
      + {list.map((r, i) => ( +
    • + {!drill && isDrillable(r) && ( + + )} +
    • + ))} + {drill && + drill.results !== null && + drill.results.length === 0 && ( +
    • + No related entries found — press ← to go back. +
    • )} - - ))} - {drill && - drill.results !== null && - drill.results.length === 0 && ( -
    • - No related entries found — press ← to go back. + {!drill && onFreeText && query.trim() !== '' && ( +
    • +
    • )} - {!drill && onFreeText && query.trim() !== '' && ( -
    • - -
    • - )} -
    -
- )} + + , + document.body + )} ); diff --git a/src/components/CountBadge/CountBadge.tsx b/src/components/CountBadge/CountBadge.tsx index 9722b9e9a..c524d9f7b 100644 --- a/src/components/CountBadge/CountBadge.tsx +++ b/src/components/CountBadge/CountBadge.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils/cn'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; +import { useClickOutside } from '../../hooks/useClickOutside'; import { MoreHorizontalIcon, ShareIcon, @@ -210,21 +212,8 @@ function RowActionMenu({ const buttonRef = React.useRef(null); // Close on outside click - React.useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if ( - menuRef.current && - !menuRef.current.contains(e.target as Node) && - buttonRef.current && - !buttonRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); + const outsideRefs = React.useMemo(() => [menuRef, buttonRef], []); + useClickOutside(outsideRefs, () => setOpen(false), open); // Close on Escape React.useEffect(() => { @@ -332,10 +321,14 @@ function HoverMenu({ items, actions, variant, + floatingRef, + style, }: { items: CountBadgeItem[]; actions: CountBadgeAction[]; variant: CountBadgeProps['variant']; + floatingRef: React.RefObject; + style: React.CSSProperties; }) { // Variant-aware header accent const headerBg: Record = { @@ -350,11 +343,13 @@ function HoverMenu({ return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
@@ -375,7 +370,7 @@ function HoverMenu({ {/* Table */}
@@ -472,22 +467,32 @@ function ViewModalActions({ const [shareOpen, setShareOpen] = React.useState(false); const shareRef = React.useRef(null); + // Portal + fixed positioning so the dropdown escapes the modal's clipping. + const { + anchorRef: shareAnchorRef, + floatingRef: shareFloatingRef, + style: shareStyle, + } = useAnchoredPosition({ + open: shareOpen, + }); + // Close share dropdown on outside click - React.useEffect(() => { - if (!shareOpen) return; - const handleClick = (e: MouseEvent) => { - if (shareRef.current && !shareRef.current.contains(e.target as Node)) { - setShareOpen(false); - } - }; - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [shareOpen]); + const shareOutsideRefs = React.useMemo( + () => [shareRef, shareFloatingRef], + [shareFloatingRef] + ); + useClickOutside(shareOutsideRefs, () => setShareOpen(false), shareOpen); return (
{/* Share with dropdown */} -
+
{ + shareRef.current = node; + shareAnchorRef.current = node; + }} + className="relative" + > - {shareOpen && ( -
- - - -
- )} + + + +
, + document.body + )}
- {showMenu && open && ( - - )} + {showMenu && + open && + ReactDOM.createPortal( + , + document.body + )}
{/* Delete confirmation modal */} diff --git a/src/components/CountryCodeDropdown/CountryCodeDropdown.tsx b/src/components/CountryCodeDropdown/CountryCodeDropdown.tsx index 107e8eec1..d224f9227 100644 --- a/src/components/CountryCodeDropdown/CountryCodeDropdown.tsx +++ b/src/components/CountryCodeDropdown/CountryCodeDropdown.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import * as libphonenumber from 'google-libphonenumber'; import { cn } from '../../utils/cn'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; const PhoneNumberUtil = (libphonenumber as unknown as { default?: typeof libphonenumber }).default @@ -238,7 +240,17 @@ function CountryCodeDropdown({ setSearch(''); }, []); - useClickOutside(containerRef, close); + // Portal + fixed positioning so the panel escapes overflow-hidden ancestors. + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ open: isOpen, placement }); + + const outsideRefs = React.useMemo( + () => [containerRef, floatingRef], + [containerRef, floatingRef] + ); + useClickOutside(outsideRefs, close, isOpen); useEscapeKey(close, isOpen); // Focus search input when opening @@ -290,14 +302,12 @@ function CountryCodeDropdown({ [isOpen] ); - const placementClass = - placement === 'bottom-end' - ? 'top-full right-0 mt-1' - : 'top-full left-0 mt-1'; - return (
{ + containerRef.current = node; + anchorRef.current = node; + }} data-slot="country-dropdown" className="relative inline-flex" > @@ -349,98 +359,101 @@ function CountryCodeDropdown({ {/* Dropdown panel */} - {isOpen && ( -
- {/* Search input */} -
- setSearch(e.target.value)} - placeholder={searchPlaceholder} - aria-label="Search countries" - className={cn( - 'w-full rounded-lg border border-neutral-200 px-3 py-1.5 text-sm', - 'text-foreground placeholder:text-muted-foreground bg-white', - 'focus:ring-ring focus:border-transparent focus:ring-2 focus:outline-none', - 'dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' - )} - /> -
- - {/* Country list */} + {isOpen && + createPortal(
- {filtered.length === 0 ? ( -
- No countries found -
- ) : ( - filtered.map((country) => ( -
+ + {/* Country list */} +
+ {filtered.length === 0 ? ( +
+ No countries found +
+ ) : ( + filtered.map((country) => ( + - )) - )} -
-
- )} + + + {country.name} + + + {country.dialCode} + + + )) + )} +
+ , + document.body + )} ); } diff --git a/src/components/DateRangePicker/DateRangePicker.tsx b/src/components/DateRangePicker/DateRangePicker.tsx index 07b3806dd..9f8871d54 100644 --- a/src/components/DateRangePicker/DateRangePicker.tsx +++ b/src/components/DateRangePicker/DateRangePicker.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '../../utils/cn'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; import { isStorybookDocsMode } from '../../utils/environment'; import { Button } from '../Button'; import { Dropdown, DropdownItem } from '../Dropdown'; @@ -383,11 +385,27 @@ export function DateRangePicker({ // Close calendar on click outside (supports touch via hook) const wrapperRef = React.useRef(null); - useClickOutside(wrapperRef, () => { - if (isCalendarOpen) { + const outsideRefs = React.useMemo( + () => [wrapperRef, calendarRef], + [calendarRef] + ); + useClickOutside( + outsideRefs, + () => { setIsCalendarOpen(false); setSelectingEnd(false); - } + }, + isCalendarOpen + ); + + // Portal + fixed positioning for the desktop popup so it escapes + // overflow-hidden ancestors. + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ + open: !isMobileVariant && isCalendarOpen, + placement: resolvedAlign === 'end' ? 'bottom-end' : 'bottom-start', }); // Close on Escape and restore focus to trigger @@ -681,7 +699,10 @@ export function DateRangePicker({ return (
{ + wrapperRef.current = node; + anchorRef.current = node; + }} className={cn('relative inline-block', className)} data-slot="date-range-picker" > @@ -808,127 +829,134 @@ export function DateRangePicker({ )} {/* Desktop / Responsive popup */} - {!isMobileVariant && isCalendarOpen && ( -
-
- {/* Preset sidebar — hidden on small screens in responsive mode */} - {showPresets && ( -
- {finalPresets.map((preset) => ( - - ))} -
+ {!isMobileVariant && + isCalendarOpen && + createPortal( +
{ + calendarRef.current = node; + floatingRef.current = node; + }} + style={style} + className={cn( + 'overflow-auto', + 'bg-background border-border rounded-lg border shadow-lg' )} - - {/* Calendar panel */} -
- {/* Subtitle */} -

- Select a start and end date from the calendar. -

- -
setHoverDate(null)} - data-slot="date-range-calendars" - > - {/* Left month */} -
-
+ role="dialog" + aria-label="Choose date range" + data-slot="date-range-popup" + > +
+ {/* Preset sidebar — hidden on small screens in responsive mode */} + {showPresets && ( +
+ {finalPresets.map((preset) => ( + ))} +
+ )} + + {/* Calendar panel */} +
+ {/* Subtitle */} +

+ Select a start and end date from the calendar. +

+ +
setHoverDate(null)} + data-slot="date-range-calendars" + > + {/* Left month */} +
- {monthNames[leftMonth]} {leftYear} -
- {/* Show right chevron on left month in responsive single-cal mode */} - {isResponsive && ( - )} +
+ {monthNames[leftMonth]} {leftYear} +
+ {/* Show right chevron on left month in responsive single-cal mode */} + {isResponsive && ( + + )} +
+ {renderMonthGrid(leftMonth, leftYear)}
- {renderMonthGrid(leftMonth, leftYear)} -
- {/* Right month — hidden on small screens in responsive mode */} -
-
+ {/* Right month — hidden on small screens in responsive mode */} +
- {monthNames[rightMonth]} {rightYear} +
+ {monthNames[rightMonth]} {rightYear} +
+
- + {renderMonthGrid(rightMonth, rightYear)}
- {renderMonthGrid(rightMonth, rightYear)}
-
-
- )} +
, + document.body + )}
); } diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 2b882966d..67c502693 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,16 +1,15 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '../../utils/cn'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { + useAnchoredPosition, + type AnchoredPlacement, +} from '../../hooks/useAnchoredPosition'; import { inputVariants } from '../Input'; -export type DropdownPlacement = - | 'bottom-start' - | 'bottom-end' - | 'bottom' - | 'top-start' - | 'top-end' - | 'top'; +export type DropdownPlacement = AnchoredPlacement; export interface DropdownProps { /** The trigger element (usually a button) */ @@ -57,14 +56,7 @@ export interface DropdownProps { selectAllLabel?: React.ReactNode; } -const placementStyles: Record = { - 'bottom-start': 'top-full left-0 mt-2', - 'bottom-end': 'top-full right-0 mt-2', - bottom: 'top-full left-1/2 -translate-x-1/2 mt-2', - 'top-start': 'bottom-full left-0 mb-2', - 'top-end': 'bottom-full right-0 mb-2', - top: 'bottom-full left-1/2 -translate-x-1/2 mb-2', -}; +const placementOffset = 8; // matches the previous mt-2/mb-2 gap interface DropdownContextValue { multiSelect: boolean; @@ -399,9 +391,25 @@ function Dropdown({ [multiSelect, selectedValues, toggleSelectedValue] ); - useClickOutside(containerRef, handleClose, isOpen); useEscapeKey(handleClose, isOpen); + // Portal + fixed positioning so the menu escapes overflow-hidden ancestors. + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ + open: isOpen, + placement, + offset: placementOffset, + matchMinWidth: width === 'trigger', + }); + + const outsideRefs = React.useMemo( + () => [containerRef, floatingRef], + [floatingRef] + ); + useClickOutside(outsideRefs, handleClose, isOpen); + React.useEffect(() => { if (!isOpen) { setSearchQuery(''); @@ -422,12 +430,7 @@ function Dropdown({ disabled: disabled || trigger.props.disabled, }); - const widthStyle = - typeof width === 'number' - ? { width: `${width}px` } - : width === 'trigger' - ? { minWidth: '100%' } - : {}; + const widthStyle = typeof width === 'number' ? { width: `${width}px` } : {}; const filteredChildren = React.useMemo( () => filterDropdownChildren(children, searchQuery), @@ -470,80 +473,88 @@ function Dropdown({ return ( -
+
{ + containerRef.current = node; + anchorRef.current = node; + }} + className="relative inline-flex" + > {triggerElement} - {isOpen && ( -
- {searchable && ( -
- setSearchQuery(event.target.value)} - placeholder={searchPlaceholder} - aria-label={searchAriaLabel} - aria-controls={menuId} - aria-autocomplete="list" - data-slot="dropdown-search-input" - className={cn( - inputVariants({ size: 'sm' }), - 'text-sm', - 'dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' + {isOpen && + createPortal( +
+ {searchable && ( +
+ setSearchQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchAriaLabel} + aria-controls={menuId} + aria-autocomplete="list" + data-slot="dropdown-search-input" + className={cn( + inputVariants({ size: 'sm' }), + 'text-sm', + 'dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' + )} + /> +
+ )} + - )} - - )} + children + )} +
+
, + document.body + )}
); diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx index c5ffb5735..cb60987d1 100644 --- a/src/components/LanguageSelector/LanguageSelector.tsx +++ b/src/components/LanguageSelector/LanguageSelector.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils/cn'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; +import { useClickOutside } from '../../hooks/useClickOutside'; // ============================================================================= // Types @@ -137,23 +140,22 @@ export function LanguageSelector({ const [isOpen, setIsOpen] = React.useState(false); const containerRef = React.useRef(null); + // Portal + fixed positioning so the dropdown escapes overflow-hidden + // ancestors (headers, cards, …). + const { anchorRef, floatingRef, style } = useAnchoredPosition< + HTMLDivElement, + HTMLDivElement + >({ open: isOpen, matchMinWidth: true, maxHeight: 240 }); + // Find selected language const selectedLanguage = languages.find((l) => l.code === value); // Close on click outside - React.useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + const outsideRefs = React.useMemo( + () => [containerRef, floatingRef], + [floatingRef] + ); + useClickOutside(outsideRefs, () => setIsOpen(false), isOpen); // Close on escape React.useEffect(() => { @@ -172,7 +174,10 @@ export function LanguageSelector({ return (
{ + containerRef.current = node; + anchorRef.current = node; + }} data-slot="language-selector" className={cn(selectorVariants({ size }), className)} > @@ -210,47 +215,47 @@ export function LanguageSelector({ {/* Dropdown */} - {isOpen && ( -
-
    - {languages.map((language) => ( -
  • handleSelect(language)} - onKeyDown={(e) => e.key === 'Enter' && handleSelect(language)} - data-slot="language-selector-option" - className={cn( - 'flex cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors', - language.code === value - ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400' - : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700' - )} - > - {showFlags && language.flag && ( - {language.flag} - )} - {language.name} - {language.code === value && ( - - )} -
  • - ))} -
-
- )} +
    + {languages.map((language) => ( +
  • handleSelect(language)} + onKeyDown={(e) => e.key === 'Enter' && handleSelect(language)} + data-slot="language-selector-option" + className={cn( + 'flex cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors', + language.code === value + ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400' + : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700' + )} + > + {showFlags && language.flag && ( + {language.flag} + )} + {language.name} + {language.code === value && ( + + )} +
  • + ))} +
+
, + document.body + )}
); } diff --git a/src/components/Messaging/MessageComposer.tsx b/src/components/Messaging/MessageComposer.tsx index ce5986b0a..0cbe70d9a 100644 --- a/src/components/Messaging/MessageComposer.tsx +++ b/src/components/Messaging/MessageComposer.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; +import { createPortal } from 'react-dom'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils/cn'; +import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'; import type { AttachmentType, NewMessage } from './types'; import { AttachmentPicker, @@ -423,6 +425,18 @@ const MessageComposer = React.forwardRef< const mentionMenuOpen = mentionsEnabled && mention !== null && mentionSuggestions.length > 0; + + // Portal + fixed positioning so the mention menu escapes overflow-hidden + // ancestors. + const { + anchorRef: mentionAnchorRef, + floatingRef: mentionFloatingRef, + style: mentionStyle, + } = useAnchoredPosition({ + open: mentionMenuOpen, + placement: 'top-start', + maxHeight: 224, + }); // Clamp the highlight to the current suggestion range so the active option // never points at a stale/out-of-range index (the list can shrink while the // menu is open as the query narrows). The same clamped index drives @@ -711,54 +725,62 @@ const MessageComposer = React.forwardRef< )} {/* Text input */} -
- {mentionMenuOpen && ( -
    - {mentionSuggestions.map((option, i) => ( -
  • - -
  • - ))} -
- )} + {option.meta && ( + + {option.meta} + + )} + + + ))} + , + document.body + )}