Skip to content
Closed
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
12 changes: 12 additions & 0 deletions packages/react/src/views/ChatHeader/ChatHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,62 +249,72 @@ const ChatHeader = ({
id: 'thread',
onClick: () => setExclusiveState(setShowAllThreads),
iconName: 'thread',
section: 'activity',
visible: true,
},
mentions: {
label: 'Mentions',
id: 'mention',
onClick: () => setExclusiveState(setShowMentions),
iconName: 'at',
section: 'activity',
visible: true,
},
starred: {
label: 'Starred Messages',
id: 'starred',
onClick: () => setExclusiveState(setShowStarred),
iconName: 'star',
section: 'content',
visible: true,
},
pinned: {
label: 'Pinned Messages',
id: 'pinned',
onClick: () => setExclusiveState(setShowPinned),
iconName: 'pin',
section: 'content',
visible: true,
},
members: {
label: 'Members',
id: 'members',
onClick: () => setExclusiveState(setShowMembers),
iconName: 'members',
section: 'activity',
visible: isUserAuthenticated,
},
files: {
label: 'Files',
id: 'files',
onClick: () => setExclusiveState(setShowAllFiles),
iconName: 'clip',
section: 'content',
visible: isUserAuthenticated,
},
search: {
label: 'Search Messages',
id: 'search',
onClick: () => setExclusiveState(setShowSearch),
iconName: 'magnifier',
section: 'discovery',
visible: isUserAuthenticated,
},
rInfo: {
label: 'Room Information',
id: 'rInfo',
onClick: () => setExclusiveState(setShowChannelinfo),
iconName: 'info',
section: 'discovery',
visible: isUserAuthenticated,
},
logout: {
label: 'Logout',
id: 'logout',
onClick: handleLogout,
iconName: 'reply-directly',
color: 'destructive',
section: 'session',
visible: isUserAuthenticated,
},
}),
Expand Down Expand Up @@ -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;
Expand Down
198 changes: 159 additions & 39 deletions packages/ui-elements/src/components/Menu/Menu.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = '',
Expand All @@ -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 = {};
Expand All @@ -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 ? (
<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);
}}
/>
);

// ── Mobile bottom sheet ──────────────────────────────────────────────────
if (isMobile) {
return (
<Box
css={styles.wrapper}
className={appendClassNames('ec-menu-wrapper', wrapperClasses)}
style={wrapperStyles}
>
{triggerButton}

{isOpen && (
<>
{/* Backdrop */}
<Box css={styles.backdrop} onClick={close} aria-hidden="true" />

{/* Sheet */}
<Box
css={styles.sheet}
className={appendClassNames('ec-menu ec-menu--sheet', classNames)}
role="dialog"
aria-modal="true"
aria-label="Options"
>
{groupedOptions.map((section, sectionIndex) => (
<Box css={styles.section} key={section.id || sectionIndex}>
{section.title ? (
<Box css={styles.sectionTitle}>{section.title}</Box>
) : null}
{section.items.map((option, idx) => (
<MenuItem
{...option}
key={option.id || `${section.id}-${idx}`}
action={onClick(option.action, option.disabled)}
isMobile
/>
))}
</Box>
))}
</Box>
</>
)}
</Box>
);
}

// ── Desktop dropdown (unchanged) ─────────────────────────────────────────
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);
}}
/>
)}
{triggerButton}
{isOpen ? (
<Box
css={[
Expand All @@ -101,17 +213,25 @@ const Menu = ({
className={appendClassNames('ec-menu', classNames)}
style={finalStyle}
>
{options.map((option, idx) => (
<MenuItem
{...option}
key={option.id || idx}
action={onClick(option.action, option.disabled)}
/>
{groupedOptions.map((section, sectionIndex) => (
<Box css={styles.section} key={section.id || sectionIndex}>
{section.title ? (
<Box css={styles.sectionTitle}>{section.title}</Box>
) : null}
{section.items.map((option, idx) => (
<MenuItem
{...option}
key={option.id || `${section.id}-${idx}`}
action={onClick(option.action, option.disabled)}
/>
))}
</Box>
))}
</Box>
) : null}
</>
);

return useWrapper ? (
<Box
css={styles.wrapper}
Expand Down
9 changes: 9 additions & 0 deletions packages/ui-elements/src/components/Menu/Menu.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,56 @@ export const Default = {
id: 'thread',
label: 'Threads',
icon: 'thread',
section: 'activity',
},
{
id: 'mentions',
label: 'Mentions',
icon: 'at',
section: 'activity',
},
{
id: 'members',
label: 'Members',
icon: 'members',
section: 'activity',
},
{
id: 'files',
label: 'Files',
icon: 'clip',
section: 'content',
},
{
id: 'starred',
label: 'Starred',
icon: 'star',
section: 'content',
},
{
id: 'pinned',
label: 'Pinned',
icon: 'pin',
section: 'content',
},
{
id: 'search',
label: 'Search',
icon: 'magnifier',
section: 'discovery',
},
{
id: 'rInfo',
label: 'Room Information',
icon: 'info',
section: 'discovery',
},
{
id: 'logout',
label: 'Logout',
icon: 'reply-directly',
color: 'error',
section: 'session',
},
],
anchor: 'left bottom',
Expand Down
Loading
Loading