From b825e66831367975b4f52ddbce3278a9baf467ca Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 19 Mar 2026 00:11:10 +0530 Subject: [PATCH 1/2] fix(menu): add mobile bottom sheet for kebab --- .../ui-elements/src/components/Menu/Menu.js | 121 +++++++++++++----- .../src/components/Menu/Menu.styles.js | 45 +++++++ .../src/components/Menu/MenuItem.js | 9 +- 3 files changed, 137 insertions(+), 38 deletions(-) diff --git a/packages/ui-elements/src/components/Menu/Menu.js b/packages/ui-elements/src/components/Menu/Menu.js index 5ad82f5c2d..b2aba4699f 100644 --- a/packages/ui-elements/src/components/Menu/Menu.js +++ b/packages/ui-elements/src/components/Menu/Menu.js @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { css } from '@emotion/react'; import useTheme from '../../hooks/useTheme'; import { Box } from '../Box'; @@ -9,6 +9,11 @@ import { appendClassNames } from '../../lib/appendClassNames'; import { Tooltip } from '../Tooltip'; import { getMenuStyles } from './Menu.styles'; +const MOBILE_BREAKPOINT = 499; + +const getIsMobileViewport = () => + typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT; + const Menu = ({ options = [], className = '', @@ -43,17 +48,39 @@ const Menu = ({ useComponentOverrides('MenuWrapper'); const [isOpen, setOpen] = useState(false); + const [isMobile, setIsMobile] = useState(getIsMobileViewport); + const wrapperRef = useRef(null); const onClick = (action, disabled) => () => { if (!disabled) { action(); - setOpen(!isOpen); + setOpen(false); } }; + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const onResize = () => { + setIsMobile(getIsMobileViewport()); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + useEffect(() => { const onBodyClick = (e) => { - if (isOpen && !e.target.classList.contains('ec-menu-wrapper')) { + if ( + isOpen && + wrapperRef.current && + !wrapperRef.current.contains(e.target) + ) { setOpen(false); } }; @@ -65,32 +92,61 @@ const Menu = ({ }; }, [isOpen]); + const menuItems = options.map((option, idx) => ( + + )); + + const triggerButton = tooltip.isToolTip ? ( + + { + e.stopPropagation(); + setOpen((prev) => !prev); + }} + /> + + ) : ( + { + e.stopPropagation(); + setOpen((prev) => !prev); + }} + /> + ); + const optionJsx = ( <> - {tooltip.isToolTip ? ( - - { - e.stopPropagation(); - setOpen((prev) => !prev); - }} - /> - - ) : ( - { - e.stopPropagation(); - setOpen((prev) => !prev); - }} - /> - )} - {isOpen ? ( + {triggerButton} + {isOpen && isMobile ? ( + <> + setOpen(false)} /> + e.stopPropagation()} + > + {menuItems} + + + ) : null} + {isOpen && !isMobile ? ( - {options.map((option, idx) => ( - - ))} + {menuItems} ) : null} ); return useWrapper ? ( ) : ( - optionJsx + {optionJsx} ); }; diff --git a/packages/ui-elements/src/components/Menu/Menu.styles.js b/packages/ui-elements/src/components/Menu/Menu.styles.js index 2537aaaef5..b2b680225d 100644 --- a/packages/ui-elements/src/components/Menu/Menu.styles.js +++ b/packages/ui-elements/src/components/Menu/Menu.styles.js @@ -21,6 +21,28 @@ export const getMenuStyles = (theme) => { box-shadow: ${theme.shadows[1]}; background-color: ${theme.colors.background}; `, + + backdrop: css` + position: fixed; + inset: 0; + z-index: ${theme.zIndex?.menu || 1300}; + background: transparent; + `, + + sheet: css` + position: fixed; + left: 0.5rem; + right: 0.5rem; + bottom: 0.5rem; + display: flex; + flex-direction: column; + max-height: min(70vh, calc(100vh - 6rem)); + overflow-y: auto; + z-index: ${(theme.zIndex?.menu || 1300) + 1}; + border-radius: 0.75rem; + padding: 0.75rem 0; + background-color: ${theme.colors.background}; + `, }; return styles; @@ -30,6 +52,7 @@ export const getMenuItemStyles = ({ theme, mode }) => { const styles = { item: css` font-size: 14px; + font-family: inherit; display: flex; flex-direction: row; align-items: center; @@ -46,6 +69,28 @@ export const getMenuItemStyles = ({ theme, mode }) => { } `, + itemMobile: css` + font-size: 14px; + font-family: inherit; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + white-space: nowrap; + color: ${theme.colors.foreground}; + &:hover { + background-color: ${mode === 'light' + ? darken(theme.colors.background, 0.05) + : lighten(theme.colors.background, 2)}; + cursor: pointer; + } + & + & { + border-top: 1px solid ${theme.colors.border}; + } + `, + disabled: css` cursor: not-allowed !important; color: ${theme.colors.mutedForeground}; diff --git a/packages/ui-elements/src/components/Menu/MenuItem.js b/packages/ui-elements/src/components/Menu/MenuItem.js index f0c69b4e53..4fe7f55982 100644 --- a/packages/ui-elements/src/components/Menu/MenuItem.js +++ b/packages/ui-elements/src/components/Menu/MenuItem.js @@ -7,7 +7,7 @@ import { appendClassNames } from '../../lib/appendClassNames'; import { getMenuItemStyles } from './Menu.styles'; import { useTheme } from '../../hooks'; -const MenuItem = ({ icon, label, action, disabled }) => { +const MenuItem = ({ icon, label, action, disabled, isMobile = false }) => { const { classNames, styleOverrides } = useComponentOverrides( 'MenuItem', disabled && 'disabled' @@ -17,12 +17,15 @@ const MenuItem = ({ icon, label, action, disabled }) => { return ( - + {label} ); From f1acd4c227e1e70a54d3f37e88a1025d2f3f7990 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Sun, 9 Aug 2026 19:52:52 +0530 Subject: [PATCH 2/2] fix(menu): scope mobile sheet to embedded chat --- .../ui-elements/src/components/Menu/Menu.js | 239 ++++++++++++++---- .../src/components/Menu/Menu.styles.js | 15 ++ .../src/components/Menu/MenuItem.js | 14 + 3 files changed, 214 insertions(+), 54 deletions(-) diff --git a/packages/ui-elements/src/components/Menu/Menu.js b/packages/ui-elements/src/components/Menu/Menu.js index b2aba4699f..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, useRef, 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'; @@ -10,9 +11,59 @@ 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 getIsMobileViewport = () => - typeof window !== 'undefined' && window.innerWidth <= MOBILE_BREAKPOINT; +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 = [], @@ -43,13 +94,22 @@ 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, setIsMobile] = useState(getIsMobileViewport); + 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) { @@ -59,19 +119,8 @@ const Menu = ({ }; useEffect(() => { - if (typeof window === 'undefined') { - return undefined; - } - - const onResize = () => { - setIsMobile(getIsMobileViewport()); - }; - - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; + const embeddedChat = wrapperRef.current?.closest('.ec-embedded-chat'); + setPortalTarget(embeddedChat?.querySelector('#overlay-items') || null); }, []); useEffect(() => { @@ -79,7 +128,8 @@ const Menu = ({ if ( isOpen && wrapperRef.current && - !wrapperRef.current.contains(e.target) + !wrapperRef.current.contains(e.target) && + !sheetRef.current?.contains(e.target) ) { setOpen(false); } @@ -92,6 +142,80 @@ 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) => ( - { - e.stopPropagation(); - setOpen((prev) => !prev); - }} - /> + ) : ( - { - e.stopPropagation(); - setOpen((prev) => !prev); - }} - /> + ); + const mobileMenu = ( + <> +