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 */} + + ); + } + + // ── 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} );