diff --git a/packages/ui-elements/src/components/Menu/Menu.js b/packages/ui-elements/src/components/Menu/Menu.js index 5ad82f5c2d..6eb10ac620 100644 --- a/packages/ui-elements/src/components/Menu/Menu.js +++ b/packages/ui-elements/src/components/Menu/Menu.js @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; import { css } from '@emotion/react'; +import { createPortal } from 'react-dom'; import useTheme from '../../hooks/useTheme'; import { Box } from '../Box'; import { ActionButton } from '../ActionButton'; @@ -9,6 +10,61 @@ import { appendClassNames } from '../../lib/appendClassNames'; import { Tooltip } from '../Tooltip'; import { getMenuStyles } from './Menu.styles'; +const MOBILE_BREAKPOINT = 499; +const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`; +const POSITION_STYLE_PROPERTIES = new Set([ + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'inset', + 'insetBlock', + 'insetBlockStart', + 'insetBlockEnd', + 'insetInline', + 'insetInlineStart', + 'insetInlineEnd', +]); + +const useIsMobileViewport = () => { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return undefined; + } + + const mediaQuery = window.matchMedia(MOBILE_MEDIA_QUERY); + const onChange = (event) => setIsMobile(event.matches); + + setIsMobile(mediaQuery.matches); + + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', onChange); + } else { + mediaQuery.addListener(onChange); + } + + return () => { + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', onChange); + } else { + mediaQuery.removeListener(onChange); + } + }; + }, []); + + return isMobile; +}; + +const getMobileStyle = (styleOverrides) => + Object.fromEntries( + Object.entries(styleOverrides).filter( + ([property]) => !POSITION_STYLE_PROPERTIES.has(property) + ) + ); + const Menu = ({ options = [], className = '', @@ -38,22 +94,43 @@ const Menu = ({ () => ({ ...anchorStyle, ...styleOverrides }), [anchorStyle, styleOverrides] ); + const mobileStyle = useMemo( + () => getMobileStyle(styleOverrides), + [styleOverrides] + ); const { classNames: wrapperClasses, styleOverrides: wrapperStyles } = useComponentOverrides('MenuWrapper'); const [isOpen, setOpen] = useState(false); + const isMobile = useIsMobileViewport(); + const [portalTarget, setPortalTarget] = useState(null); + const wrapperRef = useRef(null); + const sheetRef = useRef(null); + const triggerRef = useRef(null); + const menuId = useId(); + const menuLabel = tooltip.text || 'Options'; const onClick = (action, disabled) => () => { if (!disabled) { action(); - setOpen(!isOpen); + setOpen(false); } }; + useEffect(() => { + const embeddedChat = wrapperRef.current?.closest('.ec-embedded-chat'); + setPortalTarget(embeddedChat?.querySelector('#overlay-items') || null); + }, []); + useEffect(() => { const onBodyClick = (e) => { - if (isOpen && !e.target.classList.contains('ec-menu-wrapper')) { + if ( + isOpen && + wrapperRef.current && + !wrapperRef.current.contains(e.target) && + !sheetRef.current?.contains(e.target) + ) { setOpen(false); } }; @@ -65,32 +142,139 @@ const Menu = ({ }; }, [isOpen]); + useEffect(() => { + if (!isOpen || !isMobile || typeof document === 'undefined') { + return undefined; + } + + const previousActiveElement = document.activeElement; + const triggerElement = triggerRef.current; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const firstMenuItem = sheetRef.current?.querySelector( + '[data-menu-item]:not([aria-disabled="true"])' + ); + (firstMenuItem || sheetRef.current)?.focus(); + + return () => { + document.body.style.overflow = previousOverflow; + if (triggerElement) { + triggerElement.focus(); + } else if (previousActiveElement?.focus) { + previousActiveElement.focus(); + } + }; + }, [isOpen, isMobile, portalTarget]); + + const onSheetKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + return; + } + + if (e.key !== 'Tab') { + return; + } + + const menuItems = Array.from( + sheetRef.current?.querySelectorAll( + '[data-menu-item]:not([aria-disabled="true"])' + ) || [] + ); + + if (menuItems.length === 0) { + e.preventDefault(); + return; + } + + const firstMenuItem = menuItems[0]; + const lastMenuItem = menuItems[menuItems.length - 1]; + + if (e.shiftKey && document.activeElement === firstMenuItem) { + e.preventDefault(); + lastMenuItem.focus(); + } else if (!e.shiftKey && document.activeElement === lastMenuItem) { + e.preventDefault(); + firstMenuItem.focus(); + } + }; + + const triggerButtonProps = { + ref: triggerRef, + ghost: true, + icon: 'kebab', + size, + 'aria-label': menuLabel, + 'aria-expanded': isOpen, + 'aria-haspopup': isMobile ? 'dialog' : 'menu', + 'aria-controls': isOpen ? menuId : undefined, + onClick: (e) => { + e.stopPropagation(); + setOpen((prev) => !prev); + }, + }; + + const menuItems = options.map((option, idx) => ( + + )); + + const triggerButton = tooltip.isToolTip ? ( + + + + ) : ( + + ); + + const mobileMenu = ( + <> +