From 54fc633af469a1422558790769dcfd5d609eb7e2 Mon Sep 17 00:00:00 2001 From: william garrity Date: Thu, 23 Jul 2026 18:05:07 -0400 Subject: [PATCH 01/10] fix: prevent clipping of dropdowns, autocompletes, and floating menus Add shared useAnchoredPosition hook that portals floating panels to document.body with fixed positioning (vertical flip, viewport clamping, max-height constraint, scroll/resize tracking, transitions suppressed so position updates never animate). Migrate 14 components from inline absolute or hand-rolled portal positioning. Extend useClickOutside to accept multiple refs for portaled menus. --- src/components/Autocomplete/Autocomplete.tsx | 151 +++++----- .../BookingDialog/BookingDialog.tsx | 77 +++-- .../BusinessHoursEditor.tsx | 53 ++-- src/components/CodeLookup/CodeLookup.tsx | 239 ++++++++-------- src/components/CountBadge/CountBadge.tsx | 196 ++++++++----- .../CountryCodeDropdown.tsx | 205 +++++++------- .../DateRangePicker/DateRangePicker.tsx | 230 ++++++++------- src/components/Dropdown/Dropdown.tsx | 195 +++++++------ .../LanguageSelector/LanguageSelector.tsx | 99 ++++--- src/components/Messaging/MessageComposer.tsx | 114 +++++--- .../PatientHeader/PatientHeader.tsx | 235 +++++++++------- .../ProviderSearchFilters.tsx | 230 ++++++++------- .../ProviderSelector/ProviderSelector.tsx | 265 ++++++++++-------- src/components/Select/Select.tsx | 68 ++--- src/hooks/index.ts | 6 + src/hooks/useAnchoredPosition.ts | 212 ++++++++++++++ src/hooks/useClickOutside.ts | 10 +- 17 files changed, 1511 insertions(+), 1074 deletions(-) create mode 100644 src/hooks/useAnchoredPosition.ts 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..1be4e6154 100644 --- a/src/components/BookingDialog/BookingDialog.tsx +++ b/src/components/BookingDialog/BookingDialog.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 { isStorybookDocsMode } from '../../utils/environment'; // ============================================================================= @@ -151,18 +153,26 @@ export function ServiceSelect({ const [isOpen, setIsOpen] = React.useState(false); const dropdownRef = React.useRef(null); + // 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 }); + React.useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) + !dropdownRef.current.contains(event.target as Node) && + !floatingRef.current?.contains(event.target as Node) ) { setIsOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + }, [floatingRef]); const toggleService = (serviceSlug: string) => { if (selectedServices.includes(serviceSlug)) { @@ -178,7 +188,10 @@ export function ServiceSelect({ return (
{ + dropdownRef.current = node; + anchorRef.current = node; + }} className={cn('relative', className)} data-slot="service-select" > @@ -218,33 +231,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..f70b12e30 100644 --- a/src/components/CountBadge/CountBadge.tsx +++ b/src/components/CountBadge/CountBadge.tsx @@ -2,6 +2,7 @@ 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 { MoreHorizontalIcon, ShareIcon, @@ -332,10 +333,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 +355,13 @@ function HoverMenu({ return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
@@ -472,22 +479,41 @@ 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)) { + if ( + shareRef.current && + !shareRef.current.contains(e.target as Node) && + !shareFloatingRef.current?.contains(e.target as Node) + ) { setShareOpen(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); - }, [shareOpen]); + }, [shareOpen, shareFloatingRef]); 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..fcfb97da3 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); 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..c64ac00aa 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,13 +385,27 @@ export function DateRangePicker({ // Close calendar on click outside (supports touch via hook) const wrapperRef = React.useRef(null); - useClickOutside(wrapperRef, () => { + const outsideRefs = React.useMemo( + () => [wrapperRef, calendarRef], + [calendarRef] + ); + useClickOutside(outsideRefs, () => { if (isCalendarOpen) { setIsCalendarOpen(false); setSelectingEnd(false); } }); + // 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 useEscapeKey(() => { setIsCalendarOpen(false); @@ -681,7 +697,10 @@ export function DateRangePicker({ return (
{ + wrapperRef.current = node; + anchorRef.current = node; + }} className={cn('relative inline-block', className)} data-slot="date-range-picker" > @@ -808,127 +827,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..6cafa4667 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..e02860105 100644 --- a/src/components/LanguageSelector/LanguageSelector.tsx +++ b/src/components/LanguageSelector/LanguageSelector.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'; // ============================================================================= // Types @@ -137,6 +139,13 @@ 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); @@ -145,7 +154,8 @@ export function LanguageSelector({ const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(e.target as Node) && + !floatingRef.current?.contains(e.target as Node) ) { setIsOpen(false); } @@ -153,7 +163,7 @@ export function LanguageSelector({ document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + }, [floatingRef]); // Close on escape React.useEffect(() => { @@ -172,7 +182,10 @@ export function LanguageSelector({ return (
{ + containerRef.current = node; + anchorRef.current = node; + }} data-slot="language-selector" className={cn(selectorVariants({ size }), className)} > @@ -210,47 +223,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 + )}