From d005007a1d34bdc9460f800cf9fd2b6caf5a1fff Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Mon, 13 Oct 2025 17:01:17 +0100 Subject: [PATCH 1/9] feat: update Header --- package.json | 2 + src/components/Layout/Header.tsx | 293 +++++++++++++----- src/components/Layout/Layout.tsx | 6 +- .../non-javascript-assets/svg-loading.d.ts | 2 +- yarn.lock | 76 +++++ 5 files changed, 300 insertions(+), 79 deletions(-) diff --git a/package.json b/package.json index 3238fa7a35..f4e134a812 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@gfx/zopfli": "^1.0.15", "@intercom/messenger-js-sdk": "^0.0.14", "@mdx-js/react": "^2.3.0", + "@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.tsx b/src/components/Layout/Header.tsx index be8cddad38..784b8594e9 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -1,19 +1,61 @@ -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 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'; -type HeaderProps = { - searchBar?: boolean; -}; +const CLI_ENABLED = false; +const MAX_MOBILE_MENU_WIDTH = '560px'; + +const desktopTabs = [ + + Docs + , + + Examples + , +]; + +const mobileTabs = ['Docs', 'Examples']; -const Header: React.FC = ({ searchBar = true }) => { +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 = () => { const location = useLocation(); const userContext = useContext(UserContext); const sessionState = { @@ -23,21 +65,40 @@ 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); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isMobileMenuOpen && + mobileMenuRef.current && + !mobileMenuRef.current.contains(event.target as Node) && + burgerButtonRef.current && + !burgerButtonRef.current.contains(event.target as Node) + ) { + setIsMobileMenuOpen(false); + } + }; - const desktopTabs = [ - - Docs - , - - Examples - , - ]; - const mobileTabs = ['Docs', 'Examples']; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [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..8c58a14efd 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,8 +39,8 @@ const Layout: React.FC = ({ children, pageContext }) => { return ( -
-
+
+
{leftSidebar ? : null} {leftSidebar ? : null} diff --git a/src/types/non-javascript-assets/svg-loading.d.ts b/src/types/non-javascript-assets/svg-loading.d.ts index dda274a423..cdb2b1a9a2 100644 --- a/src/types/non-javascript-assets/svg-loading.d.ts +++ b/src/types/non-javascript-assets/svg-loading.d.ts @@ -1,4 +1,4 @@ declare module '*.svg' { - const content: unknown; + const content: string; export default content; } diff --git a/yarn.lock b/yarn.lock index 6c0e6d9821..138a8391f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3160,6 +3160,16 @@ "@radix-ui/react-primitive" "2.1.0" "@radix-ui/react-slot" "1.2.0" +"@radix-ui/react-collection@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec" + integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-compose-refs@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz" @@ -3233,11 +3243,29 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-escape-keydown" "1.1.1" +"@radix-ui/react-dropdown-menu@^2.1.16": + version "2.1.16" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz#5ee045c62bad8122347981c479d92b1ff24c7254" + integrity sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-menu" "2.1.16" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-focus-guards@1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz" integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== +"@radix-ui/react-focus-guards@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz#2a5669e464ad5fde9f86d22f7fdc17781a4dfa7f" + integrity sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw== + "@radix-ui/react-focus-scope@1.1.4": version "1.1.4" resolved "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz" @@ -3247,6 +3275,15 @@ "@radix-ui/react-primitive" "2.1.0" "@radix-ui/react-use-callback-ref" "1.1.1" +"@radix-ui/react-focus-scope@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz#dfe76fc103537d80bf42723a183773fd07bfb58d" + integrity sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-id@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz" @@ -3261,6 +3298,30 @@ dependencies: "@radix-ui/react-use-layout-effect" "1.1.1" +"@radix-ui/react-menu@2.1.16": + version "2.1.16" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz#528a5a973c3a7413d3d49eb9ccd229aa52402911" + integrity sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.11" + "@radix-ui/react-focus-guards" "1.1.3" + "@radix-ui/react-focus-scope" "1.1.7" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.8" + "@radix-ui/react-portal" "1.1.9" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.11" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + "@radix-ui/react-navigation-menu@^1.2.4": version "1.2.5" resolved "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.5.tgz" @@ -3396,6 +3457,21 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-roving-focus@1.1.11": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz#ef54384b7361afc6480dcf9907ef2fedb5080fd9" + integrity sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-select@^2.2.2": version "2.2.2" resolved "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.2.tgz" From c562b58c378980b3db8968634e7881d160630602 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Mon, 20 Oct 2025 17:20:03 +0100 Subject: [PATCH 2/9] feat: add redesigned left navigation sidebar --- package.json | 1 + src/components/Layout/Header.tsx | 25 +- src/components/Layout/Layout.tsx | 2 +- src/components/Layout/LeftSidebar.tsx | 365 +++++++++++--------------- src/components/Layout/utils/nav.ts | 30 --- tailwind.config.js | 16 ++ yarn.lock | 17 +- 7 files changed, 211 insertions(+), 245 deletions(-) diff --git a/package.json b/package.json index f4e134a812..cf3e1dcb9f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@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", diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 784b8594e9..9cbc68f149 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -2,6 +2,7 @@ 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 TabMenu from '@ably/ui/core/TabMenu'; import Logo from '@ably/ui/core/images/logo/ably-logo.svg'; @@ -13,6 +14,8 @@ import UserContext from 'src/contexts/user-context'; import ExamplesList from '../Examples/ExamplesList'; import Link from '../Link'; +// Tailwind 'md' breakpoint from tailwind.config.js +const MD_BREAKPOINT = 1040; const CLI_ENABLED = false; const MAX_MOBILE_MENU_WIDTH = '560px'; @@ -82,9 +85,27 @@ const Header: React.FC = () => { } }; - document.addEventListener('mousedown', handleClickOutside); + 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; + + if (targetElement && searchBar) { + targetElement.appendChild(searchBar); + } + + window.addEventListener('mousedown', handleClickOutside); + window.addEventListener('resize', handleResize); return () => { - document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('resize', handleResize); + handleResize.cancel(); }; }, [isMobileMenuOpen]); diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 8c58a14efd..ffcd507afc 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -40,7 +40,7 @@ const Layout: React.FC = ({ children, pageContext }) => { return (
-
+
{leftSidebar ? : null} {leftSidebar ? : null} diff --git a/src/components/Layout/LeftSidebar.tsx b/src/components/Layout/LeftSidebar.tsx index 3b6582cbca..ca7dfb07d9 100644 --- a/src/components/Layout/LeftSidebar.tsx +++ b/src/components/Layout/LeftSidebar.tsx @@ -1,238 +1,181 @@ -import { useMemo, useState, useEffect, useRef } from 'react'; -import { navigate, useLocation } from '@reach/router'; +import * as Accordion from '@radix-ui/react-accordion'; import cn from '@ably/ui/core/utils/cn'; -import Accordion from '@ably/ui/core/Accordion'; -import { AccordionData } from '@ably/ui/core/Accordion/types'; import Icon from '@ably/ui/core/Icon'; -import { throttle } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; import { productData } from 'src/data'; -import { NavProduct, NavProductContent, NavProductPages } from 'src/data/nav/types'; -import { - commonAccordionOptions, - composeNavLinkId, - hierarchicalKey, - PageTreeNode, - sidebarAlignmentClasses, - sidebarAlignmentStyles, -} from './utils/nav'; +import { NavProductContent, NavProductPage } from 'src/data/nav/types'; import Link from '../Link'; import { useLayoutContext } from 'src/contexts/layout-context'; -type ContentType = 'content' | 'api'; - type LeftSidebarProps = { inHeader?: boolean; }; -const NavPage = ({ - depth, - page, - indices, - type, - indentLinks, - inHeader, -}: { - depth: number; - page: NavProductPages; - indices: number[]; - type: ContentType; - inHeader: boolean; - indentLinks?: boolean; -}) => { - const location = useLocation(); - const linkId = 'link' in page ? composeNavLinkId(page.link) : undefined; - const { activePage } = useLayoutContext(); - const treeMatch = indices.every((value, index) => value === activePage.tree[index]?.index); - - if ('link' in page) { - const language = new URLSearchParams(location.search).get('lang'); - const pageActive = treeMatch && page.link === activePage.page.link; +const accordionContentClassName = + 'overflow-hidden data-[state=open]:animate-accordion-down data-[state=closed]:animation-accordion-up'; - return ( - - {page.name} - {page.external ? : null} - - ); - } else { - return ( - ( -
- -
- )), - }, - ]} - {...commonAccordionOptions(page, treeMatch ? 0 : undefined, false, inHeader)} - /> - ); - } -}; +const accordionTriggerClassName = + 'flex items-center justify-between gap-2 p-0 pr-2 w-full text-left ui-text-label3 bg-neutral-000 dark:bg-neutral-1300 hover:bg-neutral-100 dark:hover:bg-neutral-1200 active:bg-neutral-200 dark:active:bg-neutral-1100 text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000 [&[data-state=open]>svg]:rotate-90 focus-base transition-colors'; -const renderProductContent = ( - content: NavProductContent[], - type: ContentType, - inHeader: boolean, - productIndex: number, -) => - content.map((productContent, productContentIndex) => ( -
-
{productContent.name}
- {productContent.pages.map((page, pageIndex) => ( - - ))} -
- )); +const accordionLinkClassName = + 'pl-3 py-[6px] text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000'; -const constructProductNavData = (activePageTree: PageTreeNode[], inHeader: boolean): AccordionData[] => { - const navData: AccordionData[] = Object.entries(productData).map(([productKey, productObj], index) => { - const product = productObj.nav as NavProduct; - const apiReferencesId = `${productKey}-api-references`; +const iconClassName = 'text-neutral-1300 dark:text-neutral-000 transition-transform'; - return { - name: product.name, - icon: - activePageTree[0]?.page.name === product.name - ? { name: product.icon.open, css: 'text-orange-600' } - : { name: product.icon.closed }, - onClick: () => { - // When a product is clicked, find and scroll to any open accordion element - if (typeof document !== 'undefined') { - // Use setTimeout to ensure the DOM has updated after the click and animation has completed - setTimeout(() => { - const targetAccordion = window.innerWidth >= 1040 ? 'left-nav' : 'mobile-nav'; - const menuContainer = document.getElementById(targetAccordion); - const openAccordion: HTMLElement | null = menuContainer - ? menuContainer.querySelector('[data-state="open"] > button') - : null; +const boldClassName = 'text-neutral-1300 dark:text-neutral-000 font-bold'; - if (openAccordion) { - menuContainer?.scrollTo({ - top: openAccordion.offsetTop, - behavior: 'smooth', - }); - } - }, 200); - } - }, - content: ( -
- {product.showJumpLink ? ( - { - e.preventDefault(); - if (typeof document !== 'undefined') { - const element = document.getElementById(apiReferencesId); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - } - }} - > - Jump to API references - - ) : null} - {renderProductContent(product.content, 'content', inHeader, index)} - {product.api.length > 0 ? ( -
- {renderProductContent(product.api, 'api', inHeader, index)} -
- ) : null} -
- ), - }; - }); +const ChildAccordion = ({ + content, + layer, + tree, +}: { + content: (NavProductPage | NavProductContent)[]; + layer: number; + tree: number[]; +}) => { + const { activePage } = useLayoutContext(); + const activeTriggerRef = useRef(null); + const previousTree = activePage.tree.map(({ index }) => index).slice(0, layer + 2); - // Add a Home entry at the start of navData if inHeader is true - if (inHeader) { - navData.unshift({ - name: 'Home', - content: null, - onClick: () => { - navigate('/docs'); - }, - interactive: false, - }); - } + useEffect(() => { + if (activeTriggerRef.current) { + setTimeout(() => { + activeTriggerRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }, 200); + } + }, []); - return navData; + return ( + 0 && 'pl-3')} + defaultValue={[`item-${previousTree.join('-')}`]} + > + {content.map((page, index) => { + const hasDeeperLayer = 'pages' in page && page.pages; + const isActiveLink = 'link' in page && page.link === activePage.page.link; + const isActiveAncestor = (() => { + const arr1 = [...tree, index]; + const arr2 = activePage.tree.map(({ index }) => index).slice(0, layer + 2); + return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i]); + })(); + + return ( + + 0, + 'border-orange-600 bg-orange-100 hover:bg-orange-100': isActiveLink, + })} + > + {hasDeeperLayer ? ( +
+ {page.name} +
+ ) : ( + 'link' in page && ( + + {page.name} + + ) + )} + {hasDeeperLayer ? ( + + ) : null} +
+ {hasDeeperLayer && ( + + + + )} +
+ ); + })} +
+ ); }; const LeftSidebar = ({ inHeader = false }: LeftSidebarProps) => { const { activePage } = useLayoutContext(); - const [hasScrollbar, setHasScrollbar] = useState(false); - const sidebarRef = useRef(null); - - useEffect(() => { - const checkScrollbar = throttle(() => { - if (sidebarRef.current) { - setHasScrollbar(sidebarRef.current.offsetWidth > sidebarRef.current.clientWidth); - } - }, 150); - - checkScrollbar(); - window.addEventListener('resize', checkScrollbar); - - return () => { - window.removeEventListener('resize', checkScrollbar); - }; - }, []); - - const productNavData = useMemo(() => constructProductNavData(activePage.tree, inHeader), [activePage.tree, inHeader]); + const [openProduct, setOpenProduct] = useState(`item-${activePage.tree[0]?.index}`); return ( - + ); }; diff --git a/src/components/Layout/utils/nav.ts b/src/components/Layout/utils/nav.ts index 739b098cdd..1a0c009d96 100644 --- a/src/components/Layout/utils/nav.ts +++ b/src/components/Layout/utils/nav.ts @@ -1,5 +1,3 @@ -import cn from '@ably/ui/core/utils/cn'; -import { AccordionProps } from '@ably/ui/core/Accordion'; import { HEADER_HEIGHT, componentMaxHeight } from '@ably/ui/core/utils/heights'; import { ProductData, ProductKey } from 'src/data/types'; import { NavProductContent, NavProductPage, NavProductPages } from 'src/data/nav/types'; @@ -137,32 +135,6 @@ export const formatNavLink = (link: string) => { return link.replace(/\/$/, ''); }; -export const commonAccordionOptions = ( - currentPage: NavProductContent | null, - openIndex: number | undefined, - topLevel: boolean, - inHeader: boolean, -): Omit => ({ - icons: { open: { name: 'icon-gui-chevron-up-micro' }, closed: { name: 'icon-gui-chevron-down-micro' } }, - options: { - autoClose: topLevel, - headerCSS: cn( - 'text-neutral-1000 dark:text-neutral-300 md:text-neutral-900 dark:md:text-neutral-400 hover:text-neutral-1100 active:text-neutral-1000 !py-0 pl-0 !mb-0 transition-colors [&_svg]:!w-6 [&_svg]:!h-6 md:[&_svg]:!w-5 md:[&_svg]:!h-5', - { - 'my-3': topLevel && inHeader, - 'h-10 ui-text-label1 !font-bold md:ui-text-label4 px-4': topLevel, - 'min-h-[1.625em] md:min-h-[1.375em] ui-text-label2 !font-semibold md:ui-text-label4': !topLevel, - }, - ), - selectedHeaderCSS: '!text-neutral-1300 mb-2', - contentCSS: '[&>div]:pb-0', - rowIconSize: '20px', - defaultOpenIndexes: !inHeader && openIndex !== undefined ? [openIndex] : [], - hideBorders: true, - fullyOpen: !topLevel && currentPage?.expand, - }, -}); - export const sidebarAlignmentClasses = 'absolute md:sticky w-60 md:pb-32 pt-6'; export const sidebarAlignmentStyles: React.CSSProperties = { @@ -170,7 +142,5 @@ export const sidebarAlignmentStyles: React.CSSProperties = { height: componentMaxHeight(HEADER_HEIGHT), }; -export const composeNavLinkId = (link: string) => `nav-link-${formatNavLink(link).replaceAll('/', '-')}`; - export const hierarchicalKey = (id: string, depth: number, tree?: PageTreeNode[]) => [...(tree ? tree.slice(0, depth).map((node) => node.index) : []), id].join('-'); diff --git a/tailwind.config.js b/tailwind.config.js index 6ce2650021..66041d7af5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -15,6 +15,22 @@ module.exports = extendConfig((ablyUIConfig) => ({ gridTemplateColumns: { 'header-layout': '173px minmax(200px, 400px) 1fr', }, + keyframes: { + ...ablyUIConfig.theme.extend.keyframes, + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + ...ablyUIConfig.theme.extend.animation, + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, }, }, })); diff --git a/yarn.lock b/yarn.lock index 138a8391f6..0ff086ad80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3088,6 +3088,21 @@ "@radix-ui/react-primitive" "2.0.0" "@radix-ui/react-use-controllable-state" "1.1.0" +"@radix-ui/react-accordion@^1.2.12": + version "1.2.12" + resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz#1fd70d4ef36018012b9e03324ff186de7a29c13f" + integrity sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collapsible" "1.1.12" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-arrow@1.1.4": version "1.1.4" resolved "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz" @@ -3116,7 +3131,7 @@ "@radix-ui/react-use-controllable-state" "1.1.0" "@radix-ui/react-use-layout-effect" "1.1.0" -"@radix-ui/react-collapsible@^1.1.12": +"@radix-ui/react-collapsible@1.1.12", "@radix-ui/react-collapsible@^1.1.12": version "1.1.12" resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz#e2cc69a4490a2920f97c3c3150b0bf21281e3c49" integrity sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA== From b6cde769c2d260049b7a31743d41058e90e8e36b Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Fri, 7 Nov 2025 14:50:56 +0000 Subject: [PATCH 3/9] feat: add redesigned right sidebar nav with stepped functionality --- src/components/Layout/Layout.tsx | 2 +- src/components/Layout/RightSidebar.tsx | 488 ++++++++++++------------- src/components/Layout/utils/nav.ts | 8 - 3 files changed, 234 insertions(+), 264 deletions(-) diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index ffcd507afc..abffc61a3d 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -40,7 +40,7 @@ const Layout: React.FC = ({ children, pageContext }) => { return (
-
+
{leftSidebar ? : null} {leftSidebar ? : null} diff --git a/src/components/Layout/RightSidebar.tsx b/src/components/Layout/RightSidebar.tsx index 824f580fc8..70a14a57de 100644 --- a/src/components/Layout/RightSidebar.tsx +++ b/src/components/Layout/RightSidebar.tsx @@ -1,256 +1,298 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { useLocation, WindowLocation } from '@reach/router'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useLocation } from '@reach/router'; import cn from '@ably/ui/core/utils/cn'; -import Icon from '@ably/ui/core/Icon'; -import { IconName } from '@ably/ui/core/Icon/types'; import { componentMaxHeight, HEADER_HEIGHT, HEADER_BOTTOM_MARGIN } from '@ably/ui/core/utils/heights'; -import Tooltip from '@ably/ui/core/Tooltip'; -import { track } from '@ably/ui/core/insights'; - -import { LanguageSelector } from './LanguageSelector'; -import { useLayoutContext } from 'src/contexts/layout-context'; -import { productData } from 'src/data'; -import { LanguageKey } from 'src/data/languages/types'; -import { languageInfo } from 'src/data/languages'; -import { ActivePage, sidebarAlignmentClasses, sidebarAlignmentStyles } from './utils/nav'; import { INKEEP_ASK_BUTTON_HEIGHT } from './utils/heights'; type SidebarHeader = { id: string; type: string; label: string; + stepNumber?: string; }; -const githubBasePathTextile = 'https://github.com/ably/docs/blob/main/content'; -const githubBasePathMDX = 'https://github.com/ably/docs/blob/main/src/pages/docs'; -const requestBasePath = 'https://github.com/ably/docs/issues/new'; - -const customGithubPaths = { - '/how-to/pub-sub': 'https://github.com/ably/docs/blob/main/how-tos/pub-sub/how-to.mdx', -} as Record; - -const externalLinks = ( - activePage: ActivePage, - location: WindowLocation, -): { label: string; icon: IconName; link: string; type: string }[] => { - if (!activePage) { - return []; +const getHeaderId = (element: Element) => + element.querySelector('a')?.getAttribute('id') ?? element.querySelector('a')?.getAttribute('name') ?? element.id; + +// Paddings for various indentations of headers, 12px apart +const INDENT_MAP: Record = { + H2: 'pl-4', + H3: 'pl-7', + H4: 'pl-10', + H5: 'pl-[52px]', // there is no pl-13 + H6: 'pl-16', +} as const; + +// Paddings for various indentations of stepped headers, 12px apart with a 1rem buffer for the step indicators +const STEPPED_INDENT_MAP: Record = { + H2: 'pl-8', + H3: 'pl-11', + H4: 'pl-14', + H5: 'pl-[68px]', // there is no pl-17 + H6: 'pl-20', +} as const; + +// The height of a single line row +const FALLBACK_HEADER_HEIGHT = 28; + +const getElementIndent = (type: string, isStepped: boolean) => { + if (isStepped && STEPPED_INDENT_MAP[type]) { + return STEPPED_INDENT_MAP[type]; } - let githubEditPath = '#'; - const githubPathName = location.pathname.replace('docs/', ''); - - if (customGithubPaths[githubPathName]) { - githubEditPath = customGithubPaths[githubPathName]; - } else if (activePage.template === 'mdx') { - githubEditPath = - githubBasePathMDX + (activePage.page.index ? `${githubPathName}/index.mdx` : `${githubPathName}.mdx`); - } else { - githubEditPath = - githubBasePathTextile + (activePage.page.index ? `${githubPathName}/index.textile` : `${githubPathName}.textile`); + if (INDENT_MAP[type]) { + return INDENT_MAP[type]; } - const language = activePage.languages.length > 0 ? activePage.language : null; - const requestTitle = `Change request for: ${activePage.page.link}`; - const requestBody = encodeURIComponent(` - **Page name**: ${activePage.page.name} - **URL**: [${activePage.page.link}](https://ably.com${activePage.page.link}) - ${language && languageInfo[language] ? `Language: **${languageInfo[language].label}**` : ''} - - **Requested change or enhancement**: -`); - - return [ - { - label: 'Edit on GitHub', - icon: 'icon-social-github-mono', - link: githubEditPath, - type: 'github', - }, - { - label: 'Request changes', - icon: 'icon-gui-hand-raised-outline', - link: `${requestBasePath}?title=${requestTitle}&body=${requestBody}`, - type: 'request', - }, - ]; -}; - -const llmLinks = ( - activePage: ActivePage, - language: LanguageKey, -): { model: string; label: string; icon: IconName; link: string }[] => { - const prompt = `Tell me more about ${activePage.product ? productData[activePage.product]?.nav.name : 'Ably'}'s '${activePage.page.name}' feature from https://ably.com${activePage.page.link}${language ? ` for ${languageInfo[language]?.label}` : ''}`; - const gptPath = `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`; - const claudePath = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`; - - return [ - { model: 'gpt', label: 'ChatGPT', icon: 'icon-tech-openai', link: gptPath }, - { model: 'claude', label: 'Claude (must be logged in)', icon: 'icon-tech-claude-mono', link: claudePath }, - ]; -}; - -const getHeaderId = (element: Element) => { - const customId = element.querySelector('a')?.getAttribute('id') ?? element.querySelector('a')?.getAttribute('name'); - return customId ?? element.id; + return 'pl-0'; }; const RightSidebar = () => { const location = useLocation(); - const { activePage } = useLayoutContext(); const [headers, setHeaders] = useState([]); const [activeHeader, setActiveHeader] = useState>({ id: location.hash ? location.hash.slice(1) : '#', }); + const [isStepped, setIsStepped] = useState(false); + const [sidebarDimensions, setSidebarDimensions] = useState<{ + indicatorHeights: number[]; + indicatorPosition: { yOffset: number; height: number }; + }>({ + indicatorHeights: [], + indicatorPosition: { yOffset: 0, height: 28 }, + }); const intersectionObserver = useRef(undefined); const manualSelection = useRef(false); - const showLanguageSelector = activePage?.languages.length > 0; - const language = new URLSearchParams(location.search).get('lang') as LanguageKey; - - const handleHeaderClick = useCallback((headerId: string) => { - // Set manual selection flag to prevent intersection observer updates - manualSelection.current = true; - setActiveHeader({ id: headerId }); - - // Reset the flag after scroll animation completes - setTimeout(() => { - manualSelection.current = false; - }, 1000); + + // Extract headers from article element + const extractHeaders = useCallback((articleElement: Element): { headers: SidebarHeader[]; hasSteps: boolean } => { + const headerElements = articleElement.querySelectorAll('h2, h3, h4, h5, h6'); + let hasSteps = false; + + const headers = Array.from(headerElements) + .filter((element) => element.id && element.textContent) + .map((header) => { + const stepNumber = header.querySelector('[data-step]')?.getAttribute('data-step'); + if (stepNumber) { + hasSteps = true; + } + + return { + type: header.tagName, + label: header.textContent ?? '', + id: getHeaderId(header), + stepNumber: stepNumber || undefined, + }; + }); + + return { headers, hasSteps }; }, []); + // Callback that fires when header intersects with the viewport + const handleIntersect = useCallback( + ( + entries: { + target: Element; + isIntersecting: boolean; + boundingClientRect: DOMRect; + }[], + ) => { + // Skip updates if the header was manually selected + if (manualSelection.current) { + return; + } + + // Get all currently intersecting headers + const intersectingEntries = entries.filter((entry) => entry.isIntersecting); + + if (intersectingEntries.length > 0) { + // Find the entry nearest to the top of the viewport + const topEntry = intersectingEntries.reduce((nearest, current) => { + return current.boundingClientRect.top < nearest.boundingClientRect.top ? current : nearest; + }, intersectingEntries[0]); + + if (topEntry.target.id) { + setActiveHeader({ + id: getHeaderId(topEntry.target), + }); + } + } + }, + [], + ); + + // Setup intersection observer and update headers when page changes useEffect(() => { const articleElement = document.querySelector('article'); if (!articleElement) { return; } - const updateHeaders = () => { - const headerElements = articleElement.querySelectorAll('h2, h3, h6') ?? []; - const headerData = Array.from(headerElements) - .filter((element) => element.id && element.textContent) - .map((header) => { - const customId = - header.querySelector('a')?.getAttribute('id') ?? header.querySelector('a')?.getAttribute('name'); - - return { - type: header.tagName, - label: header.textContent ?? '', - id: customId ?? header.id, - height: header.getBoundingClientRect().height, - }; - }); + // Extract and set headers + const { headers: headerData, hasSteps } = extractHeaders(articleElement); + setHeaders(headerData); + setIsStepped(hasSteps); - setHeaders(headerData); + // Set the first header as active when page changes + if (headerData.length > 0) { + setActiveHeader({ id: headerData[0].id }); + } - // Set the first header as active when page changes - if (headerData.length > 0) { - setActiveHeader({ id: headerData[0].id }); - } + // Create intersection observer + const observer = new IntersectionObserver(handleIntersect, { + root: null, + threshold: 0, + rootMargin: '-64px 0px -80% 0px', // Account for header and focus on top of viewport + }); - const handleIntersect = ( - entries: { - target: Element; - isIntersecting: boolean; - boundingClientRect: DOMRect; - }[], - ) => { - // Skip updates if manual selection is active - if (manualSelection.current) { - return; - } + // Observe all headers + const headerElements = articleElement.querySelectorAll('h2, h3, h4, h5, h6'); + headerElements.forEach((header) => observer.observe(header)); - // Get all currently intersecting headers - const intersectingEntries = entries.filter((entry) => entry.isIntersecting); + // Store observer reference for cleanup + intersectionObserver.current = observer; - if (intersectingEntries.length > 0) { - // Find the entry nearest to the top of the viewport - const topEntry = intersectingEntries.reduce((nearest, current) => { - return current.boundingClientRect.top < nearest.boundingClientRect.top ? current : nearest; - }, intersectingEntries[0]); + // Cleanup + return () => { + observer.disconnect(); + }; + }, [location.pathname, location.search, extractHeaders, handleIntersect]); - if (topEntry.target.id) { - setActiveHeader({ - id: getHeaderId(topEntry.target), - }); - } - } - }; - - // Create a new observer with configuration focused on the top of the viewport - intersectionObserver.current = new IntersectionObserver(handleIntersect, { - root: null, - // Using a small threshold to detect partial visibility - threshold: 0, - // Account for 64px header bar at top and focus on top portion of viewport - rootMargin: '-64px 0px -80% 0px', - }); + // Calculate sidebar dimensions after DOM is ready + useEffect(() => { + if (headers.length === 0) { + return; + } - // Observe each header - headerElements.forEach((header) => { - intersectionObserver.current?.observe(header); + const calculateDimensions = () => { + // Calculate step indicator heights (for stepped mode) + const indicatorHeights = isStepped + ? headers.map((header) => { + const sidebarElement = document.getElementById(`sidebar-${header.id}`); + const sidebarElementDimensions = sidebarElement?.getBoundingClientRect(); + return sidebarElementDimensions?.height ?? FALLBACK_HEADER_HEIGHT; + }) + : []; + + // Calculate indicator position (for non-stepped mode) + const sidebarElement = document.getElementById(`sidebar-${activeHeader?.id}`); + const sidebarParentElement = sidebarElement?.parentElement; + const sidebarElementDimensions = sidebarElement?.getBoundingClientRect(); + + const indicatorPosition = + sidebarParentElement && sidebarElementDimensions + ? { + yOffset: Math.abs(sidebarParentElement.getBoundingClientRect().top - sidebarElementDimensions.top), + height: sidebarElementDimensions.height, + } + : { yOffset: 0, height: FALLBACK_HEADER_HEIGHT }; + + setSidebarDimensions({ + indicatorHeights, + indicatorPosition, }); - - return () => { - intersectionObserver.current?.disconnect(); - }; }; - updateHeaders(); - }, [location.pathname, location.search]); + calculateDimensions(); - const highlightPosition = useMemo(() => { - const sidebarElement = - typeof document !== 'undefined' ? document.getElementById(`sidebar-${activeHeader?.id}`) : null; - const sidebarParentElement = sidebarElement?.parentElement; - const sidebarElementDimensions = sidebarElement?.getBoundingClientRect(); + window.addEventListener('resize', calculateDimensions); - if (!sidebarParentElement || !sidebarElementDimensions) { - return { - yOffset: 0, - height: 21, - }; - } + // Watch each individual sidebar item for dimension changes (catches async updates, i.e. font pop-in) + const resizeObservers: ResizeObserver[] = []; + headers.forEach((header) => { + const sidebarElement = document.getElementById(`sidebar-${header.id}`); + if (sidebarElement) { + const observer = new ResizeObserver(() => { + calculateDimensions(); + }); + observer.observe(sidebarElement); + resizeObservers.push(observer); + } + }); - return { - yOffset: Math.abs(sidebarParentElement.getBoundingClientRect().top - sidebarElementDimensions?.top), - height: sidebarElementDimensions?.height, + return () => { + window.removeEventListener('resize', calculateDimensions); + resizeObservers.forEach((observer) => observer.disconnect()); }; - }, [activeHeader]); + }, [headers, isStepped, activeHeader]); + + const { indicatorHeights, indicatorPosition } = sidebarDimensions; + + const steppedHeader = useCallback( + (header: SidebarHeader, index: number) => ( +
+
+
+ {header.stepNumber ?? ''} +
+
+
+ ), + [indicatorHeights, headers.length, activeHeader?.id], + ); return (
- {showLanguageSelector ? : null} -
+
{headers.length > 0 ? ( <> -

On this page

-
-
- {/* 18px derives from the 2px width of the grey tracker bar plus the 16px between it and the menu items */} -
+

On this page

+
+ {isStepped ? ( +
{headers.map((header, index) => steppedHeader(header, index))}
+ ) : ( +
+ )} +
{headers.map((header, index) => ( handleHeaderClick(header.id)} + onClick={() => setActiveHeader({ id: header.id })} > {header.label} @@ -259,70 +301,6 @@ const RightSidebar = () => {
) : null} -
- {externalLinks(activePage, location).map(({ label, icon, link, type }) => ( - -
-
- - - {label} - -
- -
-
- ))} -
- Open in - {llmLinks(activePage, language).map(({ model, label, icon, link }) => ( - { - track('llm_link_clicked', { - model, - location: location.pathname, - link, - }); - }} - > - - } - > - {label} - - - ))} -
-
); diff --git a/src/components/Layout/utils/nav.ts b/src/components/Layout/utils/nav.ts index 1a0c009d96..23bd91c288 100644 --- a/src/components/Layout/utils/nav.ts +++ b/src/components/Layout/utils/nav.ts @@ -1,4 +1,3 @@ -import { HEADER_HEIGHT, componentMaxHeight } from '@ably/ui/core/utils/heights'; import { ProductData, ProductKey } from 'src/data/types'; import { NavProductContent, NavProductPage, NavProductPages } from 'src/data/nav/types'; import { LanguageKey } from 'src/data/languages/types'; @@ -135,12 +134,5 @@ export const formatNavLink = (link: string) => { return link.replace(/\/$/, ''); }; -export const sidebarAlignmentClasses = 'absolute md:sticky w-60 md:pb-32 pt-6'; - -export const sidebarAlignmentStyles: React.CSSProperties = { - top: HEADER_HEIGHT, - height: componentMaxHeight(HEADER_HEIGHT), -}; - export const hierarchicalKey = (id: string, depth: number, tree?: PageTreeNode[]) => [...(tree ? tree.slice(0, depth).map((node) => node.index) : []), id].join('-'); From eb867fcb4c7bf248d8e43d20cf9643ed97ef7635 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Fri, 7 Nov 2025 14:51:17 +0000 Subject: [PATCH 4/9] chore: add test page to showcase stepped right sidebar --- src/pages/docs/test-stepped-sidebar.mdx | 156 ++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/pages/docs/test-stepped-sidebar.mdx diff --git a/src/pages/docs/test-stepped-sidebar.mdx b/src/pages/docs/test-stepped-sidebar.mdx new file mode 100644 index 0000000000..367e5e813f --- /dev/null +++ b/src/pages/docs/test-stepped-sidebar.mdx @@ -0,0 +1,156 @@ +--- +title: Getting Started with Chat (Stepped Tutorial) +meta_description: "A step-by-step guide to setting up and using Ably Chat." +--- + +This is a test page to demonstrate the stepped sidebar functionality. Follow these numbered steps to get started with Ably Chat. + +## Introduction + +Before diving into the setup process, let's understand what Ably Chat provides and why you might want to use it. + +## Create a client + + +First, you need to create an Ably Chat client instance. This client will be used to interact with the Ably Chat API. + +```javascript +import * as Ably from 'ably'; +import { ChatClient } from '@ably/chat'; + +const realtimeClient = new Ably.Realtime({ + key: 'your-api-key', + clientId: 'unique-client-id' +}); + +const chatClient = new ChatClient(realtimeClient); +``` + +The client ID should be a unique identifier for the user. This is used to track which messages belong to which users. + +## Connect to a room + +Once you have a client, you can connect to a chat room. Rooms are the main way to organize conversations in Ably Chat. + +```javascript +const room = await chatClient.rooms.get('my-chat-room', { + presence: { enableEvents: true }, + typing: { heartbeatThrottleMs: 5000 } +}); + +await room.attach(); +``` + +The room ID can be any string that uniquely identifies your room. If the room doesn't exist, it will be created automatically. + +### Room configuration options Room configuration options Room configuration options Room configuration options + +You can configure various features when creating a room: + +- **Presence**: Track who is currently in the room +- **Typing indicators**: Show when users are typing +- **Occupancy**: Track the number of users in the room +- **Reactions**: Enable emoji reactions to messages + +## Send messages + +Now that you're connected to a room, you can start sending messages: + +```javascript +await room.messages.send({ text: 'Hello, world!' }); +``` + +You can also send messages with additional metadata: + +```javascript +await room.messages.send({ + text: 'Hello!', + metadata: { + type: 'greeting', + timestamp: Date.now() + } +}); +``` + +## Subscribe to messages + +To receive messages from other users, you need to subscribe to the room's message events: + +```javascript +const { unsubscribe } = room.messages.subscribe((message) => { + console.log('Received message:', message.text); + console.log('From:', message.clientId); + console.log('At:', message.timestamp); +}); + +// Later, when you want to stop receiving messages +unsubscribe(); +``` + +The subscription will continue to receive messages until you call the `unsubscribe` function. + +## Add presence tracking + +Presence tracking allows you to see who is currently in the room and track their status: + +```javascript +// Enter the room's presence set +await room.presence.enter({ status: 'online' }); + +// Subscribe to presence updates +room.presence.subscribe((presenceEvent) => { + console.log('Presence update:', presenceEvent.action); + console.log('User:', presenceEvent.clientId); + console.log('Data:', presenceEvent.data); +}); + +// Update your presence data +await room.presence.update({ status: 'away' }); + +// Leave the room's presence set +await room.presence.leave(); +``` + +## Additional features + +Beyond the core functionality, Ably Chat provides several additional features to enhance your chat experience. + +### Typing indicators + +Show when users are currently typing in the room: + +```javascript +// Start typing +await room.typing.start(); + +// Subscribe to typing events +room.typing.subscribe((event) => { + console.log(`${event.clientId} is typing`); +}); + +// Stop typing +await room.typing.stop(); +``` + +### Room reactions + +Allow users to send quick emoji reactions: + +```javascript +// Send a reaction +await room.reactions.send({ type: 'like' }); + +// Subscribe to reactions +room.reactions.subscribe((reaction) => { + console.log(`${reaction.clientId} sent: ${reaction.type}`); +}); +``` + +## Next steps + +Now that you've completed the basic setup, you can explore more advanced features: + +- Learn about [message history and pagination](/docs/chat/rooms/messages) +- Implement [occupancy tracking](/docs/chat/rooms/occupancy) +- Set up [webhooks for chat events](/docs/chat/webhooks) +- Configure [push notifications](/docs/chat/push-notifications) From 8199dbca074cffdba4bfcbb0dae5759f4d0df7d1 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Fri, 7 Nov 2025 17:09:31 +0000 Subject: [PATCH 5/9] chore: shift location of inkeep-search bar between desktop and mobile menus --- src/components/Layout/Header.tsx | 6 ++++++ src/components/SearchBar/InkeepSearchBar.tsx | 18 +++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 9cbc68f149..36f57822b5 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -13,6 +13,7 @@ import LeftSidebar from './LeftSidebar'; import UserContext from 'src/contexts/user-context'; import ExamplesList from '../Examples/ExamplesList'; import Link from '../Link'; +import { InkeepSearchBar } from '../SearchBar/InkeepSearchBar'; // Tailwind 'md' breakpoint from tailwind.config.js const MD_BREAKPOINT = 1040; @@ -71,6 +72,7 @@ const Header: React.FC = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const mobileMenuRef = useRef(null); const burgerButtonRef = useRef(null); + const searchBarRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -267,6 +269,10 @@ const Header: React.FC = () => {
+ +
+ +
); }; diff --git a/src/components/SearchBar/InkeepSearchBar.tsx b/src/components/SearchBar/InkeepSearchBar.tsx index fe228d382d..fa3edfa244 100644 --- a/src/components/SearchBar/InkeepSearchBar.tsx +++ b/src/components/SearchBar/InkeepSearchBar.tsx @@ -1,11 +1,11 @@ import { CSSProperties } from 'react'; -export const InkeepSearchBar = ({ - className, - extraInputStyle, -}: { - className: string; - extraInputStyle: CSSProperties; -}) => { - return ; -}; +import { forwardRef } from 'react'; + +export const InkeepSearchBar = forwardRef( + ({ className, extraInputStyle }, ref) => { + return ; + }, +); + +InkeepSearchBar.displayName = 'InkeepSearchBar'; From e444108049a592adebece62cf9b0daca726ee431 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Mon, 10 Nov 2025 16:14:40 +0000 Subject: [PATCH 6/9] chore: update navigation component tests to respect new functionality --- src/components/Layout/Header.test.tsx | 23 +--- src/components/Layout/LeftSidebar.test.tsx | 143 +++++++++++--------- src/components/Layout/RightSidebar.test.tsx | 84 ++---------- 3 files changed, 93 insertions(+), 157 deletions(-) diff --git a/src/components/Layout/Header.test.tsx b/src/components/Layout/Header.test.tsx index 7fc0f3a681..4da3e319b9 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.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/LeftSidebar.test.tsx b/src/components/Layout/LeftSidebar.test.tsx index a9148d1bbb..edd8d07563 100644 --- a/src/components/Layout/LeftSidebar.test.tsx +++ b/src/components/Layout/LeftSidebar.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useLocation } from '@reach/router'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import LeftSidebar from './LeftSidebar'; import { useLayoutContext } from 'src/contexts/layout-context'; @@ -23,56 +24,6 @@ jest.mock('../Link', () => { return MockLink; }); -// Mock productData -jest.mock('src/data', () => ({ - productData: { - platform: { - nav: { - name: 'Platform', - icon: { open: 'icon-gui-chevron-up-micro', closed: 'icon-gui-chevron-down-micro' }, - content: [ - { - name: 'Overview', - pages: [ - { name: 'Introduction', link: '/platform/intro' }, - { name: 'Getting Started', link: '/platform/getting-started' }, - ], - }, - ], - api: [ - { - name: 'API Overview', - pages: [ - { name: 'API Introduction', link: '/platform/api-intro' }, - { name: 'API Reference', link: '/platform/api-reference' }, - ], - }, - ], - link: '/platform', - showJumpLink: true, - }, - }, - pubsub: { - nav: { - name: 'Pub/Sub', - icon: { open: 'icon-gui-chevron-up-outline', closed: 'icon-gui-chevron-down-outline' }, - content: [ - { - name: 'Overview', - pages: [ - { name: 'Introduction', link: '/pubsub/intro' }, - { name: 'Getting Started', link: '/pubsub/getting-started' }, - ], - }, - ], - api: [], - link: '/pubsub', - showJumpLink: false, - }, - }, - }, -})); - const mockUseLayoutContext = useLayoutContext as jest.Mock; const mockUseLocation = useLocation as jest.Mock; @@ -80,10 +31,8 @@ describe('LeftSidebar', () => { beforeEach(() => { mockUseLayoutContext.mockReturnValue({ activePage: { - tree: [ - { index: 0, page: { name: 'Link 1', link: '/link-1' } }, - { index: 1, page: { name: 'Link 2', link: '/link-2' } }, - ], + page: { name: 'Test Page', link: '/platform/intro' }, + tree: [{ index: 0, page: { name: 'Link 1', link: '/link-1' } }], }, }); @@ -109,20 +58,86 @@ describe('LeftSidebar', () => { it('renders the sidebar with products', () => { render(); expect(screen.getByRole('button', { name: 'Platform' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Pub/Sub' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ably Pub/Sub' })).toBeInTheDocument(); }); - it('renders product content and API sections', () => { + it('shows Platform accordion expanded with first three child items when active page is under Platform', async () => { render(); - expect(screen.getByText('Overview')).toBeInTheDocument(); - expect(screen.getByText('API Overview')).toBeInTheDocument(); + + // Since activePage.page.link is '/platform/intro', the Platform accordion should be open by default + // Verify the labels of the first three child accordion items are visible + await waitFor(() => { + expect(screen.getByText('Introduction')).toBeInTheDocument(); + expect(screen.getByText('Architecture')).toBeInTheDocument(); + expect(screen.getByText('Products and SDKs')).toBeInTheDocument(); + }); + + // Verify these are clickable accordion triggers + const introButton = screen.getByText('Introduction').closest('button'); + const archButton = screen.getByText('Architecture').closest('button'); + const productsButton = screen.getByText('Products and SDKs').closest('button'); + + expect(introButton).toBeInTheDocument(); + expect(archButton).toBeInTheDocument(); + expect(productsButton).toBeInTheDocument(); }); - it('renders links for pages and API pages', () => { + it('expands Platform/Architecture accordion and shows first three child items', async () => { + const user = userEvent.setup(); render(); - expect(screen.getByText('Introduction')).toBeInTheDocument(); - expect(screen.getByText('Getting Started')).toBeInTheDocument(); - expect(screen.getByText('API Introduction')).toBeInTheDocument(); - expect(screen.getByText('API Reference')).toBeInTheDocument(); + + // Platform is already expanded since activePage is /platform/intro + expect(screen.queryByText('Overview')).not.toBeInTheDocument(); + expect(screen.queryByText('Edge network')).not.toBeInTheDocument(); + expect(screen.queryByText('Infrastructure operations')).not.toBeInTheDocument(); + + // Find and click Architecture button to expand it + const architectureButton = screen.getByText('Architecture').closest('button'); + if (!architectureButton) { + throw new Error('Architecture button not found'); + } + await user.click(architectureButton); + + // After clicking, verify the first three child items are visible + await waitFor(() => { + expect(screen.getByText('Overview')).toBeInTheDocument(); + expect(screen.getByText('Edge network')).toBeInTheDocument(); + expect(screen.getByText('Infrastructure operations')).toBeInTheDocument(); + }); + + // Verify these are links (leaf nodes) not accordion triggers + const overviewLink = screen.getByText('Overview').closest('a'); + const edgeNetworkLink = screen.getByText('Edge network').closest('a'); + const infrastructureLink = screen.getByText('Infrastructure operations').closest('a'); + + expect(overviewLink).toBeInTheDocument(); + expect(edgeNetworkLink).toBeInTheDocument(); + expect(infrastructureLink).toBeInTheDocument(); + }); + + it('clicks Ably Pub/Sub to close Platform and expand Pub/Sub showing first three child items', async () => { + const user = userEvent.setup(); + render(); + + // Initially on Platform page, so Platform accordion is open + expect(screen.getByText('Architecture')).toBeInTheDocument(); + + // Pub/Sub children should not be visible + expect(screen.queryByText('Authentication')).not.toBeInTheDocument(); + expect(screen.queryByText('Connections')).not.toBeInTheDocument(); + + // Click on Ably Pub/Sub button to expand it (this should close Platform since type="single") + const pubsubButton = screen.getByRole('button', { name: 'Ably Pub/Sub' }); + await user.click(pubsubButton); + + // After clicking, verify the first three Pub/Sub child accordion items appear + await waitFor(() => { + expect(screen.getByText('Introduction')).toBeInTheDocument(); + expect(screen.getByText('Authentication')).toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + }); + + // Platform's Architecture should no longer be visible since accordion closed + expect(screen.queryByText('Architecture')).not.toBeInTheDocument(); }); }); diff --git a/src/components/Layout/RightSidebar.test.tsx b/src/components/Layout/RightSidebar.test.tsx index 10f33e66cc..25659bfae9 100644 --- a/src/components/Layout/RightSidebar.test.tsx +++ b/src/components/Layout/RightSidebar.test.tsx @@ -13,10 +13,6 @@ jest.mock('@reach/router', () => ({ useLocation: jest.fn(), })); -jest.mock('./LanguageSelector', () => ({ - LanguageSelector: jest.fn(() =>
LanguageSelector
), -})); - const mockUseLayoutContext = useLayoutContext as jest.Mock; const mockUseLocation = useLocation as jest.Mock; @@ -62,83 +58,29 @@ describe('RightSidebar', () => { document.body.innerHTML = ''; }); - it('does not render the LanguageSelector component when activePage.languages is empty', () => { - render(); - expect(screen.queryByText('LanguageSelector')).not.toBeInTheDocument(); - }); - - it('renders the LanguageSelector component when activePage.languages is not empty', () => { - mockUseLayoutContext.mockReturnValue({ - activePage: { - page: { - name: 'Test Page', - link: '/test-path', - }, - tree: [0], - languages: ['javascript'], - }, - products: [['pubsub']], - }); - render(); - expect(screen.getByText('LanguageSelector')).toBeInTheDocument(); - }); - it('renders headers from the article', () => { render(); expect(screen.getByRole('heading', { level: 2, name: 'Header 1' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Header 2' })).toBeInTheDocument(); }); - it('sets active header on click', async () => { - render(); - const headerLink = await screen.findByRole('link', { name: 'Header 1' }); - fireEvent.click(headerLink); - expect(headerLink).toHaveClass('text-neutral-1300'); - }); + it('renders sidebar links for article headers', () => { + const { container } = render(); - it('renders external links', () => { - render(); - expect(screen.getByText('Edit on GitHub')).toBeInTheDocument(); - expect(screen.getByText('Request changes')).toBeInTheDocument(); - }); + // Verify sidebar links are created with correct IDs + const header1Link = container.querySelector('#sidebar-header1'); + const header2Link = container.querySelector('#sidebar-header2'); - it('renders a textile Github link when the page is textile', () => { - mockUseLayoutContext.mockReturnValue({ - activePage: { - page: { - name: 'Test Page', - link: '/test-path', - }, - template: 'textile', - tree: [0], - languages: [], - }, - products: [['pubsub']], - }); - render(); - - const githubLink = screen.getByTestId('external-github-link'); - expect(githubLink).toBeInTheDocument(); - expect(githubLink).toHaveAttribute('href', 'https://github.com/ably/docs/blob/main/content/test-path.textile'); + expect(header1Link).toBeInTheDocument(); + expect(header2Link).toBeInTheDocument(); + expect(header1Link).toHaveAttribute('href', '#header1'); + expect(header2Link).toHaveAttribute('href', '#header2'); }); - it('renders an MDX Github link when the page is MDX', () => { - mockUseLayoutContext.mockReturnValue({ - activePage: { - page: { - name: 'Test Page', - link: '/test-path', - }, - template: 'mdx', - tree: [0], - languages: [], - }, - products: [['pubsub']], - }); + it('sets active header on click', async () => { render(); - - const githubLink = screen.getByTestId('external-github-link'); - expect(githubLink).toBeInTheDocument(); - expect(githubLink).toHaveAttribute('href', 'https://github.com/ably/docs/blob/main/src/pages/docs/test-path.mdx'); + const headerLink = await screen.findByRole('link', { name: 'Header 1' }); + fireEvent.click(headerLink); + expect(headerLink).toHaveClass('text-neutral-1300'); }); }); From 31415149a92043d0846468480a6cfc5248d30966 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Mon, 10 Nov 2025 16:56:28 +0000 Subject: [PATCH 7/9] chore: render empty divs for null nav elements to preserve gap --- src/components/Layout/Layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index abffc61a3d..f869eb579e 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -41,13 +41,13 @@ const Layout: React.FC = ({ children, pageContext }) => {
- {leftSidebar ? : null} + {leftSidebar ? :
} - {leftSidebar ? : null} + {leftSidebar ? :
} {children}