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 = (
+ <>
+ setOpen(false)}
+ />
+
+ >
+ );
+
+ const renderedMobileMenu = portalTarget
+ ? createPortal(mobileMenu, portalTarget)
+ : mobileMenu;
+
const optionJsx = (
<>
- {tooltip.isToolTip ? (
-
- {
- e.stopPropagation();
- setOpen((prev) => !prev);
- }}
- />
-
- ) : (
- {
- e.stopPropagation();
- setOpen((prev) => !prev);
- }}
- />
- )}
- {isOpen ? (
+ {triggerButton}
+ {isOpen && isMobile ? renderedMobileMenu : null}
+ {isOpen && !isMobile ? (
) : 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..34e5f1e71d 100644
--- a/packages/ui-elements/src/components/Menu/Menu.styles.js
+++ b/packages/ui-elements/src/components/Menu/Menu.styles.js
@@ -21,6 +21,39 @@ 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;
+ `,
+
+ backdropInContainer: css`
+ position: absolute;
+ 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};
+ `,
+
+ sheetInContainer: css`
+ position: absolute;
+ `,
};
return styles;
@@ -30,6 +63,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 +80,32 @@ 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;
+ width: 100%;
+ white-space: nowrap;
+ color: ${theme.colors.foreground};
+ text-align: left;
+ border: 0;
+ background: transparent;
+ &: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..40a76a7a25 100644
--- a/packages/ui-elements/src/components/Menu/MenuItem.js
+++ b/packages/ui-elements/src/components/Menu/MenuItem.js
@@ -7,22 +7,39 @@ 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'
);
const theme = useTheme();
const styles = getMenuItemStyles(theme);
+ const handleKeyDown = (e) => {
+ if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ action();
+ }
+ };
return (
-
+
{label}
);