diff --git a/package.json b/package.json index 3238fa7a35..cf3e1dcb9f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@gfx/zopfli": "^1.0.15", "@intercom/messenger-js-sdk": "^0.0.14", "@mdx-js/react": "^2.3.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-tooltip": "^1.2.8", "@react-hook/media-query": "^1.1.1", "@sentry/gatsby": "^9.19.0", "@types/cheerio": "^0.22.31", diff --git a/src/components/Layout/Header.test.tsx b/src/components/Layout/Header.test.tsx index 7fc0f3a681..26852a0cde 100644 --- a/src/components/Layout/Header.test.tsx +++ b/src/components/Layout/Header.test.tsx @@ -56,40 +56,19 @@ describe('Header', () => { it('renders the header with logo and links', () => { render(
); - expect(screen.getAllByAltText('Ably logo').length).toBeGreaterThan(0); + expect(screen.getByAltText('Ably')).toBeInTheDocument(); - expect(screen.getByText('Docs')).toBeInTheDocument(); + expect(screen.getAllByText('Docs')).toHaveLength(2); expect(screen.getByText('Examples')).toBeInTheDocument(); }); - it('renders the search bar when searchBar is true', () => { - render(
); - expect(screen.getByText('SearchBar')).toBeInTheDocument(); - }); - - it('does not render the search bar when searchBar is false', () => { - render(
); - expect(screen.queryByText('SearchBar')).not.toBeInTheDocument(); - }); - it('toggles the mobile menu when the burger icon is clicked', () => { render(
); const burgerIcon = screen.getByText('icon-gui-bars-3-outline'); fireEvent.click(burgerIcon); - expect(screen.getByText('icon-gui-x-mark-outline')).toBeInTheDocument(); expect(screen.getByText('LeftSidebar')).toBeInTheDocument(); }); - it('disables scrolling when the mobile menu is open', () => { - render(
); - const burgerIcon = screen.getByText('icon-gui-bars-3-outline'); - fireEvent.click(burgerIcon); - expect(document.body).toHaveClass('overflow-hidden'); - const closeIcon = screen.getByText('icon-gui-x-mark-outline'); - fireEvent.click(closeIcon); - expect(document.body).not.toHaveClass('overflow-hidden'); - }); - it('renders the sign in buttons when not signed in', () => { render( diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index be8cddad38..6782f8d508 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -1,19 +1,65 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { useLocation } from '@reach/router'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { throttle } from 'es-toolkit/compat'; import Icon from '@ably/ui/core/Icon'; -import AblyHeader from '@ably/ui/core/Header'; -import { SearchBar } from '../SearchBar'; +import TabMenu from '@ably/ui/core/TabMenu'; +import Logo from '@ably/ui/core/images/logo/ably-logo.svg'; +import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from '@ably/ui/core/utils/heights'; +import cn from '@ably/ui/core/utils/cn'; +import { IconName } from '@ably/ui/core/Icon/types'; import LeftSidebar from './LeftSidebar'; import UserContext from 'src/contexts/user-context'; import ExamplesList from '../Examples/ExamplesList'; -import TabMenu from '@ably/ui/core/TabMenu'; import Link from '../Link'; +import { InkeepSearchBar } from '../SearchBar/InkeepSearchBar'; -type HeaderProps = { - searchBar?: boolean; -}; +// Tailwind 'md' breakpoint from tailwind.config.js +const MD_BREAKPOINT = 1040; +const CLI_ENABLED = false; +const MAX_MOBILE_MENU_WIDTH = '560px'; + +const desktopTabs = [ + + Docs + , + + Examples + , +]; + +const mobileTabs = ['Docs', 'Examples']; + +const helpResourcesItems = [ + { + href: '/support', + icon: 'icon-gui-lifebuoy-outline' as IconName, + label: 'Support', + }, + { + href: '/sdks', + icon: 'icon-gui-cube-outline' as IconName, + label: 'SDKs', + external: true, + }, + { + href: 'https://ably.com', + icon: 'icon-gui-ably-badge' as IconName, + label: 'ably.com', + external: true, + }, +]; +const tooltipContentClassName = cn( + 'px-2 py-1 bg-neutral-1000 dark:bg-neutral-300 text-neutral-200 dark:text-neutral-1100 ui-text-p3 font-medium rounded-lg relative z-50 mt-2', + 'data-[state=closed]:animate-[tooltipExit_0.25s_ease-in-out]', + 'data-[state=delayed-open]:animate-[tooltipEntry_0.25s_ease-in-out]', +); +const secondaryButtonClassName = + 'focus-base flex items-center justify-center gap-2 px-4 py-[7px] h-9 ui-text-label4 text-neutral-1300 dark:text-neutral-000 rounded border border-neutral-400 dark:border-neutral-900 hover:border-neutral-600 dark:hover:border-neutral-700'; +const iconButtonClassName = cn(secondaryButtonClassName, 'w-9 p-0'); -const Header: React.FC = ({ searchBar = true }) => { +const Header: React.FC = () => { const location = useLocation(); const userContext = useContext(UserContext); const sessionState = { @@ -23,21 +69,57 @@ const Header: React.FC = ({ searchBar = true }) => { accountName: userContext.sessionState.accountName ?? '', account: userContext.sessionState.account ?? { links: { dashboard: { href: '#' } } }, }; + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const mobileMenuRef = useRef(null); + const burgerButtonRef = useRef(null); + const searchBarRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const clickedOutsideMenu = mobileMenuRef.current && !mobileMenuRef.current.contains(target); + const clickedOutsideBurgerButton = burgerButtonRef.current && !burgerButtonRef.current.contains(target); + + if (isMobileMenuOpen && clickedOutsideMenu && clickedOutsideBurgerButton) { + setIsMobileMenuOpen(false); + } + }; + + const handleResize = throttle(() => { + if (window.innerWidth >= MD_BREAKPOINT && isMobileMenuOpen) { + setIsMobileMenuOpen(false); + } + }, 150); + + // Physically shift the inkeep search bar around given that it's initialised once + const targetId = isMobileMenuOpen ? 'inkeep-search-mobile-mount' : 'inkeep-search-mount'; + const targetElement = document.getElementById(targetId); + const searchBar = searchBarRef.current; - const desktopTabs = [ - - Docs - , - - Examples - , - ]; - const mobileTabs = ['Docs', 'Examples']; + if (targetElement && searchBar) { + targetElement.appendChild(searchBar); + } + + window.addEventListener('mousedown', handleClickOutside); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('resize', handleResize); + handleResize.cancel(); + }; + }, [isMobileMenuOpen]); return ( - +
+ + Ably + + Docs + + = ({ searchBar = true }) => { defaultTabIndex: location.pathname.includes('/examples') ? 1 : 0, }} /> - } - mobileNav={ - , - , - ]} - rootClassName="h-full overflow-y-hidden min-h-[3.1875rem] flex flex-col" - contentClassName="h-full py-4 overflow-y-scroll" - tabClassName="ui-text-menu2 !px-4" - options={{ flexibleTabWidth: true }} - /> - } - searchButton={ -
+ +
+ - } - searchButtonVisibility="mobile" - searchBar={ - searchBar ? ( - - ) : null - } - headerCenterClassName="flex-none w-52 lg:w-[17.5rem]" - headerLinks={[ - { - href: '/docs/sdks', - label: 'SDKs', - external: true, - }, - { - href: '/support', - label: 'Support', - }, - ]} - sessionState={sessionState} - logoHref="/docs" - location={location} - /> + > + + Ask AI + + + + + + + + + + Help & Resources + + + + + {helpResourcesItems.map((item) => ( + + +
+ + {item.label} +
+ {item.external && } +
+
+ ))} +
+
+
+ {CLI_ENABLED && ( + + + + + + Open CLI + + + )} + {sessionState.signedIn ? ( + <> + {sessionState.preferredEmail ? ( + + + + Dashboard + + + + {sessionState.preferredEmail} + + + ) : ( + + Dashboard + + )} + + + + + + Log out + + + + ) : ( + <> + + Login + + + Start free + + + )} +
+
+
+ +
+ +
+ +
+ ); }; diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 1afc4c4756..f869eb579e 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -31,7 +31,7 @@ type LayoutProps = PageProps; const Layout: React.FC = ({ children, pageContext }) => { const location = useLocation(); - const { searchBar, leftSidebar, rightSidebar, template } = pageContext.layout ?? {}; + const { leftSidebar, rightSidebar, template } = pageContext.layout ?? {}; const isRedocPage = location.pathname === '/docs/api/control-api' || location.pathname === '/docs/api/chat-rest' || @@ -39,15 +39,15 @@ const Layout: React.FC = ({ children, pageContext }) => { return ( -
-
- {leftSidebar ? : null} +
+
+ {leftSidebar ? :
} - {leftSidebar ? : null} + {leftSidebar ? :
} {children}