diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js
index 0986104ae5..e296f0fec2 100644
--- a/packages/react/src/views/ChatHeader/ChatHeader.js
+++ b/packages/react/src/views/ChatHeader/ChatHeader.js
@@ -249,6 +249,7 @@ const ChatHeader = ({
id: 'thread',
onClick: () => setExclusiveState(setShowAllThreads),
iconName: 'thread',
+ section: 'activity',
visible: true,
},
mentions: {
@@ -256,6 +257,7 @@ const ChatHeader = ({
id: 'mention',
onClick: () => setExclusiveState(setShowMentions),
iconName: 'at',
+ section: 'activity',
visible: true,
},
starred: {
@@ -263,6 +265,7 @@ const ChatHeader = ({
id: 'starred',
onClick: () => setExclusiveState(setShowStarred),
iconName: 'star',
+ section: 'content',
visible: true,
},
pinned: {
@@ -270,6 +273,7 @@ const ChatHeader = ({
id: 'pinned',
onClick: () => setExclusiveState(setShowPinned),
iconName: 'pin',
+ section: 'content',
visible: true,
},
members: {
@@ -277,6 +281,7 @@ const ChatHeader = ({
id: 'members',
onClick: () => setExclusiveState(setShowMembers),
iconName: 'members',
+ section: 'activity',
visible: isUserAuthenticated,
},
files: {
@@ -284,6 +289,7 @@ const ChatHeader = ({
id: 'files',
onClick: () => setExclusiveState(setShowAllFiles),
iconName: 'clip',
+ section: 'content',
visible: isUserAuthenticated,
},
search: {
@@ -291,6 +297,7 @@ const ChatHeader = ({
id: 'search',
onClick: () => setExclusiveState(setShowSearch),
iconName: 'magnifier',
+ section: 'discovery',
visible: isUserAuthenticated,
},
rInfo: {
@@ -298,6 +305,7 @@ const ChatHeader = ({
id: 'rInfo',
onClick: () => setExclusiveState(setShowChannelinfo),
iconName: 'info',
+ section: 'discovery',
visible: isUserAuthenticated,
},
logout: {
@@ -305,6 +313,8 @@ const ChatHeader = ({
id: 'logout',
onClick: handleLogout,
iconName: 'reply-directly',
+ color: 'destructive',
+ section: 'session',
visible: isUserAuthenticated,
},
}),
@@ -335,6 +345,8 @@ const ChatHeader = ({
action: options[item].onClick,
label: options[item].label,
icon: options[item].iconName,
+ color: options[item].color,
+ section: options[item].section,
};
}
return null;
diff --git a/packages/ui-elements/src/components/Menu/Menu.js b/packages/ui-elements/src/components/Menu/Menu.js
index 5ad82f5c2d..afeb896bd9 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, { useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import useTheme from '../../hooks/useTheme';
import { Box } from '../Box';
@@ -9,6 +9,58 @@ import { appendClassNames } from '../../lib/appendClassNames';
import { Tooltip } from '../Tooltip';
import { getMenuStyles } from './Menu.styles';
+const MOBILE_BREAKPOINT = 768;
+
+const DEFAULT_SECTION_ID = '__default';
+
+const getOptionSection = (option) => {
+ if (!option?.section) {
+ return { id: DEFAULT_SECTION_ID, title: '' };
+ }
+
+ if (typeof option.section === 'string') {
+ return { id: option.section, title: '' };
+ }
+
+ return {
+ id: option.section.id || DEFAULT_SECTION_ID,
+ title: option.section.title || '',
+ };
+};
+
+const groupOptions = (options) =>
+ options.reduce((sections, option) => {
+ const section = getOptionSection(option);
+ const existingSection = sections.find(({ id }) => id === section.id);
+
+ if (existingSection) {
+ existingSection.items.push(option);
+ return sections;
+ }
+
+ sections.push({
+ ...section,
+ items: [option],
+ });
+
+ return sections;
+ }, []);
+
+const useIsMobile = () => {
+ const [isMobile, setIsMobile] = useState(
+ () => window.innerWidth < MOBILE_BREAKPOINT
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const handler = (e) => setIsMobile(e.matches);
+ mql.addEventListener('change', handler);
+ return () => mql.removeEventListener('change', handler);
+ }, []);
+
+ return isMobile;
+};
+
const Menu = ({
options = [],
className = '',
@@ -18,13 +70,16 @@ const Menu = ({
size = 'medium',
useWrapper = true,
}) => {
- const { theme } = useTheme();
- const styles = getMenuStyles(theme);
+ const { theme, mode } = useTheme();
+ const styles = getMenuStyles({ theme, mode });
+ const isMobile = useIsMobile();
+
const { classNames, styleOverrides } = useComponentOverrides(
'Menu',
className,
style
);
+
const anchorStyle = useMemo(() => {
const positions = anchor.split(/\s+/);
const styleAnchor = {};
@@ -43,53 +98,110 @@ const Menu = ({
useComponentOverrides('MenuWrapper');
const [isOpen, setOpen] = useState(false);
+ const groupedOptions = useMemo(() => groupOptions(options), [options]);
+
+ const close = useCallback(() => setOpen(false), []);
const onClick = (action, disabled) => () => {
if (!disabled) {
action();
- setOpen(!isOpen);
+ setOpen(false);
}
};
+ // Close on outside click (desktop only — mobile uses backdrop)
useEffect(() => {
+ if (isMobile || !isOpen) return undefined;
const onBodyClick = (e) => {
- if (isOpen && !e.target.classList.contains('ec-menu-wrapper')) {
+ if (!e.target.classList.contains('ec-menu-wrapper')) {
setOpen(false);
}
};
-
document.addEventListener('click', onBodyClick);
+ return () => document.removeEventListener('click', onBodyClick);
+ }, [isOpen, isMobile]);
- return () => {
- document.removeEventListener('click', onBodyClick);
- };
- }, [isOpen]);
+ // Close on Escape
+ useEffect(() => {
+ if (!isOpen) return undefined;
+ const onKey = (e) => e.key === 'Escape' && close();
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [isOpen, close]);
+
+ const triggerButton = tooltip.isToolTip ? (
+
+ {
+ e.stopPropagation();
+ setOpen((prev) => !prev);
+ }}
+ />
+
+ ) : (
+ {
+ e.stopPropagation();
+ setOpen((prev) => !prev);
+ }}
+ />
+ );
+ // ── Mobile bottom sheet ──────────────────────────────────────────────────
+ if (isMobile) {
+ return (
+
+ {triggerButton}
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Sheet */}
+
+ {groupedOptions.map((section, sectionIndex) => (
+
+ {section.title ? (
+ {section.title}
+ ) : null}
+ {section.items.map((option, idx) => (
+
+ ))}
+
+ ))}
+
+ >
+ )}
+
+ );
+ }
+
+ // ── Desktop dropdown (unchanged) ─────────────────────────────────────────
const optionJsx = (
<>
- {tooltip.isToolTip ? (
-
- {
- e.stopPropagation();
- setOpen((prev) => !prev);
- }}
- />
-
- ) : (
- {
- e.stopPropagation();
- setOpen((prev) => !prev);
- }}
- />
- )}
+ {triggerButton}
{isOpen ? (
- {options.map((option, idx) => (
-
+ {groupedOptions.map((section, sectionIndex) => (
+
+ {section.title ? (
+ {section.title}
+ ) : null}
+ {section.items.map((option, idx) => (
+
+ ))}
+
))}
) : null}
>
);
+
return useWrapper ? (
{
+ const { theme: currentTheme, mode } = theme;
+ const surfaceColor =
+ mode === 'light'
+ ? currentTheme.colors.background
+ : lighten(currentTheme.colors.background, 1.25);
const styles = {
wrapper: css`
position: relative;
@@ -13,13 +18,75 @@ export const getMenuStyles = (theme) => {
right: 0;
display: flex;
flex-direction: column;
- width: fit-content;
+ width: max-content;
+ min-width: 220px;
height: fit-content;
- z-index: ${theme.zIndex?.menu || 1300};
- border-radius: 0.2em;
+ z-index: ${currentTheme.zIndex?.menu || 1300};
+ border-radius: 0.5rem;
padding: 0.5rem 0;
- box-shadow: ${theme.shadows[1]};
- background-color: ${theme.colors.background};
+ border: 1px solid ${currentTheme.colors.border};
+ box-shadow: ${currentTheme.shadows[2]};
+ background-color: ${surfaceColor};
+ `,
+
+ backdrop: css`
+ position: fixed;
+ inset: 0;
+ z-index: ${(currentTheme.zIndex?.menu || 1300) - 1};
+ background: rgba(0, 0, 0, 0.42);
+ animation: ec-fade-in 0.18s ease;
+ @keyframes ec-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+ `,
+
+ sheet: css`
+ position: fixed;
+ bottom: 0;
+ left: 0.5rem;
+ right: 0.5rem;
+ z-index: ${currentTheme.zIndex?.menu || 1300};
+ background-color: ${surfaceColor};
+ color: ${currentTheme.colors.foreground};
+ border: 1px solid ${currentTheme.colors.border};
+ border-bottom: none;
+ border-radius: 0.75rem 0.75rem 0 0;
+ box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.28);
+ max-height: min(72vh, calc(100vh - 5.5rem));
+ overflow-y: auto;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ overscroll-behavior: contain;
+ animation: ec-slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
+ @keyframes ec-slide-up {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+ }
+ `,
+
+ section: css`
+ padding-block: 0.25rem;
+
+ & + & {
+ border-top: 1px solid ${currentTheme.colors.border};
+ }
+ `,
+
+ sectionTitle: css`
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: ${currentTheme.colors.mutedForeground};
+ padding: 0.25rem 1rem 0.5rem;
`,
};
@@ -29,23 +96,52 @@ export const getMenuStyles = (theme) => {
export const getMenuItemStyles = ({ theme, mode }) => {
const styles = {
item: css`
- font-size: 14px;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
+ sans-serif;
+ font-size: 0.9375rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
- padding: 0.25em 0.75em;
+ padding: 0.625rem 1rem;
white-space: nowrap;
- gap: 0.2rem;
+ gap: 0.75rem;
color: ${theme.colors.foreground};
+ line-height: 1.25rem;
+ transition: background-color 0.15s ease;
&:hover {
background-color: ${mode === 'light'
- ? darken(theme.colors.background, 0.05)
- : lighten(theme.colors.background, 2)};
+ ? darken(theme.colors.background, 0.04)
+ : lighten(theme.colors.background, 2.25)};
cursor: pointer;
}
`,
+ itemMobile: css`
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
+ sans-serif;
+ font-size: 0.9375rem;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 0.5rem 1rem;
+ min-height: 2.25rem;
+ gap: 0.75rem;
+ color: ${theme.colors.foreground};
+ line-height: 1.25rem;
+ transition: background-color 0.15s ease;
+ &:active {
+ background-color: ${mode === 'light'
+ ? darken(theme.colors.background, 0.04)
+ : lighten(theme.colors.background, 2.25)};
+ }
+ `,
+
+ destructive: css`
+ color: ${theme.colors.destructive};
+ `,
+
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..3f9543e5d0 100644
--- a/packages/ui-elements/src/components/Menu/MenuItem.js
+++ b/packages/ui-elements/src/components/Menu/MenuItem.js
@@ -7,22 +7,34 @@ 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,
+ color,
+}) => {
const { classNames, styleOverrides } = useComponentOverrides(
'MenuItem',
disabled && 'disabled'
);
const theme = useTheme();
const styles = getMenuItemStyles(theme);
+ const isDestructive = color === 'destructive' || color === 'error';
return (
-
+
{label}
);