Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 217 additions & 35 deletions packages/ui-elements/src/components/Menu/Menu.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = '',
Expand Down Expand Up @@ -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);
}
};
Expand All @@ -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) => (
<MenuItem
{...option}
key={option.id || idx}
action={onClick(option.action, option.disabled)}
isMobile={isMobile}
/>
));

const triggerButton = tooltip.isToolTip ? (
<Tooltip text={tooltip.text} position={tooltip.position}>
<ActionButton {...triggerButtonProps} />
</Tooltip>
) : (
<ActionButton {...triggerButtonProps} />
);

const mobileMenu = (
<>
<Box
css={portalTarget ? styles.backdropInContainer : styles.backdrop}
aria-hidden="true"
onClick={() => setOpen(false)}
/>
<Box
ref={sheetRef}
css={[
styles.sheet,
portalTarget && styles.sheetInContainer,
css`
box-shadow: ${theme.shadows[2]};
`,
]}
className={appendClassNames('ec-menu ec-menu-mobile', classNames)}
id={menuId}
role="dialog"
aria-modal="true"
aria-label={menuLabel}
tabIndex={-1}
style={mobileStyle}
onKeyDown={onSheetKeyDown}
onClick={(e) => e.stopPropagation()}
>
<Box role="menu" aria-label={menuLabel}>
{menuItems}
</Box>
</Box>
</>
);

const renderedMobileMenu = portalTarget
? createPortal(mobileMenu, portalTarget)
: mobileMenu;

const optionJsx = (
<>
{tooltip.isToolTip ? (
<Tooltip text={tooltip.text} position={tooltip.position}>
<ActionButton
ghost
icon="kebab"
size={size}
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
/>
</Tooltip>
) : (
<ActionButton
ghost
icon="kebab"
size={size}
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
/>
)}
{isOpen ? (
{triggerButton}
{isOpen && isMobile ? renderedMobileMenu : null}
{isOpen && !isMobile ? (
<Box
css={[
styles.container,
Expand All @@ -99,29 +283,27 @@ const Menu = ({
`,
]}
className={appendClassNames('ec-menu', classNames)}
id={menuId}
role="menu"
aria-label={menuLabel}
style={finalStyle}
>
{options.map((option, idx) => (
<MenuItem
{...option}
key={option.id || idx}
action={onClick(option.action, option.disabled)}
/>
))}
{menuItems}
</Box>
) : null}
</>
);
return useWrapper ? (
<Box
ref={wrapperRef}
css={styles.wrapper}
className={appendClassNames('ec-menu-wrapper', wrapperClasses)}
style={wrapperStyles}
>
{optionJsx}
</Box>
) : (
optionJsx
<Box ref={wrapperRef}>{optionJsx}</Box>
);
};

Expand Down
60 changes: 60 additions & 0 deletions packages/ui-elements/src/components/Menu/Menu.styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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};
Expand Down
Loading