From 3c68ea2278ffcf0d2e06ee394d64a37847ed43fb Mon Sep 17 00:00:00 2001 From: Parikshit Deshmukh Date: Fri, 21 Aug 2026 18:18:09 +0530 Subject: [PATCH] Restructure the primary nav into Product and Resources menus Replaces the flat top level (OpenUI Cloud, Docs, Demos, Lab, Benchmarks, Blogs) with Product, Resources, Documentation and a badged Benchmarks. Desktop: - Menus can group their children, and a group can be labelled. Product splits into Open source and Managed; Resources into Demos, Lab projects, Tools and Other. - Adds a "list" layout for menus too long for a row of art cards. Resources holds twelve items, which at the card track would need a 2488px panel; it lists them as columns of icon-and-title rows instead. - Panels now centre on the window rather than on their trigger, since both are wide enough that hanging them off one link only pushed them into an edge clamp. - Hovering a Resources row expands the panel to reveal a one-line description. The panel measures its own height and clips to it, so the banner is mounted on hover and the height animates to suit. - Observability is announced but not linked yet: it renders as inert text with a Coming soon tag rather than a link. Mobile: - The tray takes its sections from the same data. A long menu lists its groups as sections; a short one stays whole under its own name. - Sections are an accordion, one open at a time, opening on Product. - Top-level links with no menu of their own fold into the last section, and the deliberately duplicated Benchmarks entry is dropped there. The New and Coming soon chips share one solid inverted treatment, tuned per theme rather than inverted from one another. Co-Authored-By: Claude Opus 5 --- .../site-marketing-header.module.css | 148 +++-- docs/components/site-marketing-header.tsx | 156 +++--- docs/components/site-primary-nav.module.css | 305 +++++++++- docs/components/site-primary-nav.tsx | 530 ++++++++++++++---- 4 files changed, 921 insertions(+), 218 deletions(-) diff --git a/docs/components/site-marketing-header.module.css b/docs/components/site-marketing-header.module.css index 4a50436e0..f9b9b2660 100644 --- a/docs/components/site-marketing-header.module.css +++ b/docs/components/site-marketing-header.module.css @@ -65,9 +65,19 @@ .mobileTray { pointer-events: auto; + display: flex; + flex-direction: column; + /* Everything below the header, so the tray is the whole screen rather than a + panel hanging off the top of it. dvh rather than vh: on mobile the browser's + own chrome moves, and vh would put the footer under it. + + The offset mirrors --site-header-row-min-height in site-header.module.css. + It is copied rather than referenced because the tray renders outside + .surface and so does not inherit it; keep the two in step by hand. */ + height: calc(100dvh - 4rem); + max-height: calc(100dvh - 4rem); + overflow-y: auto; border-top: 1px solid var(--openui-border-default); - border-bottom-left-radius: 28px; - border-bottom-right-radius: 28px; background: var(--openui-foreground); box-shadow: var(--openui-shadow-xl); } @@ -84,10 +94,13 @@ .mobileTrayInner { display: flex; + width: 100%; + min-height: 100%; max-width: 75rem; margin-inline: auto; flex-direction: column; - gap: 1.5rem; + /* The filled title bars separate the sections now, so they can sit close. */ + gap: 0.375rem; padding: 0.5rem 1.25rem 1rem; } @@ -95,32 +108,58 @@ display: flex; flex-direction: column; gap: 0.125rem; - padding-block: 0.5rem; + padding-block: 0.125rem; } +/* A filled bar rather than a bare label, so a collapsed section still reads as + something you can open. The same element serves both kinds: Product renders it + as a div with no chevron, the rest as a button with one. */ .mobileTraySectionHeading { position: relative; - padding: 0.25rem 0.625rem 0.375rem; - font-family: var(--font-geist-mono), ui-monospace, monospace; - font-size: 0.6875rem; + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.25rem; + padding: 0.8125rem 0.75rem; + border: 0; + border-radius: 0.625rem; + background: rgba(10, 10, 10, 0.04); + font-family: "Inter", sans-serif; + font-size: 0.8125rem; font-weight: 500; - letter-spacing: 0.02em; - line-height: 1; + letter-spacing: 0; + line-height: 1.2; + text-align: left; text-transform: none; - color: var(--openui-text-neutral-tertiary, var(--openui-text-neutral-secondary)); + color: var(--openui-text-neutral-secondary); } -/* Separator below each section title: runs to the right edge, fades left. Sits - centered in the gap (same -1.5px offset as the inter-tab separators) so the first - tab in each section has the same spacing above it as the tabs below. */ -.mobileTraySectionHeading::after { - content: ""; - position: absolute; - bottom: calc(-0.0625rem - 0.5px); - left: 0.625rem; - right: -1.25rem; - height: 1px; - background: linear-gradient(to right, transparent, var(--openui-border-default) 14%); +[data-theme="dark"] .mobileTraySectionHeading { + background: rgba(255, 255, 255, 0.06); +} + +.mobileTraySectionHeading { + cursor: pointer; +} + +.mobileTrayChevron { + flex: none; + opacity: 0.7; + transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1); +} + +.mobileTrayChevronOpen { + transform: rotate(180deg); +} + +/* Clipped so the height animation has something to reveal. */ +.mobileTraySectionItems { + display: flex; + flex-direction: column; + gap: 0.125rem; + overflow: hidden; } .mobileTrayDivider { @@ -133,10 +172,10 @@ position: relative; display: flex; width: 100%; - min-height: 2.25rem; + min-height: 3rem; align-items: center; gap: 0.5rem; - padding: 0.375rem 0.625rem; + padding: 0.6875rem 0.625rem; border-radius: 0.375rem; color: var(--openui-text-neutral-primary); text-align: left; @@ -149,33 +188,58 @@ letter-spacing: -0.01em; } -/* Separator between every tab within a section: runs to the right edge, fades left. */ +/* Matches .badge in the primary nav: same job, same neutral, same dark rule. */ +/* A hairline between adjacent items only. Not under the section titles and not + below the last item: the filled title bars already divide the sections, so a + rule there would be saying it twice. Sits in the 2px gap between rows, inset + to the item's own padding so it lines up with the text. */ .mobileTrayLink + .mobileTrayLink::before { content: ""; position: absolute; - top: calc(-0.0625rem - 0.5px); + top: -1px; + right: 0.625rem; left: 0.625rem; - right: -1.25rem; height: 1px; - background: linear-gradient(to right, transparent, var(--openui-border-default) 14%); + background: rgba(10, 10, 10, 0.07); } -/* Separator below the last tab in each section too (mirrors the inter-tab one). */ -.mobileTrayLink:last-child::after { - content: ""; - position: absolute; - bottom: calc(-0.0625rem - 0.5px); - left: 0.625rem; - right: -1.25rem; - height: 1px; - background: linear-gradient(to right, transparent, var(--openui-border-default) 14%); +[data-theme="dark"] .mobileTrayLink + .mobileTrayLink::before { + background: rgba(255, 255, 255, 0.08); +} + +/* Same chip as .mobileTrayBadge below: solid and inverted. */ +.mobileTrayComingSoon { + padding: 0.125rem 0.375rem; + border-radius: 4px; + background: #0a0a0a; + color: #ffffff; + font-family: "Inter", sans-serif; + font-size: 9px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +[data-theme="dark"] .mobileTrayComingSoon { + background: #f3f3f4; + color: #0d0d0f; +} + +.mobileTrayLinkMuted { + cursor: default; +} + +.mobileTrayLinkMuted > span:first-child { + opacity: 0.55; } .mobileTrayBadge { padding: 0.125rem 0.375rem; border-radius: 4px; - background: rgba(124, 58, 237, 0.12); - color: #7c3aed; + background: #0a0a0a; + color: #ffffff; font-size: 9px; font-weight: 600; line-height: 1; @@ -183,11 +247,19 @@ text-transform: uppercase; } +[data-theme="dark"] .mobileTrayBadge { + background: #f3f3f4; + color: #0d0d0f; +} + /* GitHub button pinned to the bottom of the mobile tray, separated like a section. */ .mobileTrayFooter { display: flex; flex-direction: column; gap: 0.625rem; + /* Takes up the slack, so the button sits at the bottom of the screen when the + sections are collapsed and is simply pushed down when they are not. */ + margin-top: auto; padding-top: 1.5rem; } diff --git a/docs/components/site-marketing-header.tsx b/docs/components/site-marketing-header.tsx index 370b2f4b9..ceae04eb9 100644 --- a/docs/components/site-marketing-header.tsx +++ b/docs/components/site-marketing-header.tsx @@ -4,12 +4,14 @@ import { GitHubButton } from "@/app/(home)/components/GitHubButton/GitHubButton" import { type LogoVariant } from "@/components/brand-logo"; import { SiteHeaderFrame } from "@/components/site-header"; import { + dropdownChildren, + dropdownGroups, isNavDropdown, PRIMARY_SITE_NAV_ITEMS, SitePrimaryNav, } from "@/components/site-primary-nav"; import { ThemeToggle } from "@/components/theme-toggle"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, ChevronDown } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTheme } from "next-themes"; import Link from "next/link"; @@ -56,63 +58,74 @@ function HamburgerIcon({ isOpen }: { isOpen: boolean }) { ); } +/* The tray takes its sections straight from the nav data: one per dropdown, in + nav order, then everything that has no menu of its own gathered under + Resources. Product used to be assembled here by name, because it existed only + as scattered top-level links; it is a real menu now, so it arrives with the + rest. + + The Open source / Managed split is flattened away. It divides three links, and + two heading rows to separate them costs more than it explains at tray width. */ function MobileMenu({ onClose }: { onClose: () => void }) { const leafItems = PRIMARY_SITE_NAV_ITEMS.filter( (item): item is Extract<(typeof PRIMARY_SITE_NAV_ITEMS)[number], { href: string }> => !isNavDropdown(item), ); - const dropdownSections = PRIMARY_SITE_NAV_ITEMS.filter(isNavDropdown); - - const openuiCloud = leafItems.find((item) => item.title === "OpenUI Cloud"); - const agentInterface = leafItems.find((item) => item.title === "Agent Interface"); - const otherLeafItems = leafItems.filter( - (item) => item !== openuiCloud && item !== agentInterface, + /* A short menu stays one section under its own name. A long one lists its + groups as sections instead, because a single heading over eleven links tells + you nothing about where you are in them. Resources is the only menu long + enough to need it, which is what `layout: "list"` already marks. */ + const sections = PRIMARY_SITE_NAV_ITEMS.filter(isNavDropdown).flatMap((menu) => + menu.layout === "list" + ? dropdownGroups(menu).map((group) => ({ title: group.label, items: group.children })) + : [{ title: menu.title, items: dropdownChildren(menu) }], ); - const productSection = { - title: "Product", - items: [ - { title: "OpenUI", href: "/", newTab: false, badge: undefined as string | undefined }, - ...(openuiCloud - ? [ - { - title: openuiCloud.title, - href: openuiCloud.href, - newTab: openuiCloud.newTab, - badge: openuiCloud.badge, - }, - ] - : []), - ...(agentInterface - ? [ - { - title: agentInterface.title, - href: agentInterface.href, - newTab: agentInterface.newTab, - badge: agentInterface.badge, - }, - ] - : []), - ], - }; + /* Top-level links that no section already covers get folded into the last one + rather than trailing loose underneath it. Benchmarks is deliberately not one + of them: it is duplicated in the nav on purpose while it is new, and the + tray already lists it under Other, so the second copy is dropped here. */ + const covered = new Set(sections.flatMap((section) => section.items.map((item) => item.href))); + const extras = leafItems.filter((leaf) => !covered.has(leaf.href)); + if (extras.length > 0 && sections.length > 0) { + const last = sections[sections.length - 1]; + sections[sections.length - 1] = { ...last, items: [...last.items, ...extras] }; + } + + /* One open section at a time, starting on the first: the tray should open with + something to read rather than a wall of closed headings, and Product is what + leads the nav. Collapsible like the rest, just not collapsed to begin with. */ + const [openSection, setOpenSection] = useState(sections[0]?.title ?? null); const renderTrayLink = (entry: { title: string; href: string; newTab?: boolean; badge?: string; - }) => ( - - {entry.title} - {entry.badge && {entry.badge}} - - ); + comingSoon?: true; + }) => + /* Matches the desktop menu: announced, tagged, and not a link. */ + entry.comingSoon ? ( + + {entry.title} + Coming soon + + ) : ( + + {entry.title} + {entry.badge && {entry.badge}} + + ); return ( <> @@ -133,24 +146,41 @@ function MobileMenu({ onClose }: { onClose: () => void }) { >
-
-
{productSection.title}
- {productSection.items.map(renderTrayLink)} -
- - {otherLeafItems.length > 0 && ( -
-
Resources
- {otherLeafItems.map(renderTrayLink)} -
- )} - - {dropdownSections.map((section) => ( -
-
{section.title}
- {section.children.map(renderTrayLink)} -
- ))} + {sections.map((section) => { + const isOpen = openSection === section.title; + return ( +
+ + + {isOpen && ( + + {section.items.map(renderTrayLink)} + + )} + +
+ ); + })}
group.children) : item.children; +} + +/* The groups to render. An ungrouped menu is one unlabelled group, so the markup + below has a single path through it rather than a branch. Exported for the + mobile tray, which lists a long menu's groups as sections of their own. */ +export function dropdownGroups(item: NavDropdown): NavGroup[] { + return item.groups ?? [{ label: "", children: item.children }]; +} + +/* The groups folded into the columns they actually occupy. A group marked + `stacked` joins the column before it, so the panel is as wide as its columns + rather than as its groups. */ +function dropdownColumns(item: NavDropdown): NavGroup[][] { + const columns: NavGroup[][] = []; + for (const group of dropdownGroups(item)) { + if (group.stacked && columns.length > 0) columns[columns.length - 1].push(group); + else columns.push([group]); + } + return columns; +} + export const PRIMARY_SITE_NAV_ITEMS: NavItem[] = [ - { title: "OpenUI Cloud", href: "/cloud", newTab: false }, - { title: "Docs", href: "/docs", newTab: false }, { - // Order and titles follow the menu design (Figma node 756:545). - title: "Demos", - children: [ - { - title: "Compare", - description: "See how AI apps look with and without OpenUI.", - href: "/compare", - preview: { - light: "/nav/compare-light.webp", - dark: "/nav/compare-dark.webp", - }, - }, - { - title: "Dashboard example", - description: "A demo built on GitHub data that responds with dashboard.", - href: "/demo/github", - preview: { - light: "/nav/dashboard-light.webp", - dark: "/nav/dashboard-dark.webp", - }, - }, + title: "Product", + cardTrack: 200, + groups: [ { - title: "OpenUI Chat", - description: "A ChatGPT-like assistant powered by OpenUI.", - href: "/chat", - preview: { - light: "/nav/chat-light.webp", - dark: "/nav/chat-dark.webp", - }, + label: "Open source", + children: [ + { + title: "OpenUI", + description: "The open standard for generative UI.", + href: "/", + }, + ], }, { - title: "OpenUI vs JSON", - description: "See how OpenUI runs 3x faster on 67% fewer tokens.", - href: "/demos", - preview: { - light: "/nav/vsjson-light.webp", - dark: "/nav/vsjson-dark.webp", - }, + label: "Managed", + children: [ + { + title: "OpenUI Cloud", + description: "Production agent interfaces, hosted and managed.", + href: "/cloud", + }, + { + title: "Observability", + description: "Product analytics and user insights for AI agents.", + href: "/cloud/observability", + comingSoon: true, + }, + ], }, ], }, { - title: "Lab", - children: [ + title: "Resources", + layout: "list", + /* The longest title ("Community projects") measures 153px unwrapped; the row + adds 6px padding, a 24px tile, an 8px gap and 6px padding to that. 200 + clears it with a little slack and keeps every row on one line. */ + cardTrack: 200, + groups: [ { - title: "OpenClaw OS", - description: "A power-packed workspace for your OpenClaw agents.", - href: "/openclaw-os", - preview: { - light: "/nav/openclaw-light.webp", - dark: "/nav/openclaw-dark.webp", - }, + label: "Demos", + children: [ + { + title: "Compare", + description: "See how AI apps look with and without OpenUI", + href: "/compare", + icon: ArrowsOutLineHorizontal, + }, + { + title: "OpenUI Chat", + description: "A ChatGPT-like assistant powered by OpenUI", + href: "/chat", + icon: ChatCenteredText, + }, + { + title: "AI Dashboards", + description: "A demo built on GitHub data that answers with a dashboard", + href: "/demo/github", + icon: ChartPieSlice, + }, + { + title: "OpenUI vs JSON", + description: + "Compare OpenUI Lang with JSON-based UI generation: 3× faster with up to 67% fewer tokens.", + href: "/demos", + icon: Code, + }, + ], }, { - title: "AppLess", - description: "An open-source concept for an OS without any apps.", - href: "https://github.com/thesysdev/appless", - newTab: true, - preview: { - light: "/nav/appless-light.webp", - dark: "/nav/appless-dark.webp", - }, + label: "Lab projects", + children: [ + { + title: "OpenClaw OS", + description: "A power-packed workspace for your OpenClaw agents", + href: "/openclaw-os", + icon: Monitor, + }, + { + title: "AppLess", + description: "An open-source concept for an OS without any apps", + href: "https://github.com/thesysdev/appless", + newTab: true, + icon: DeviceMobileCamera, + }, + { + title: "By community", + description: "Tools, packages, plugins, and demos from the community", + href: "/lab", + icon: Users, + }, + ], }, { - title: "Community projects", - description: "Tools, packages, plugins, demos, and more.", - href: "/lab", - preview: { - light: "/nav/community-light.webp", - dark: "/nav/community-dark.webp", - }, + label: "Tools", + children: [ + /* Both Tools rows point into the developer tools docs, at their own + sections. Relative, so they stay client-side navigations and resolve + to openui.com in production rather than pointing previews at live. */ + { + title: "Debug", + description: "Reproduce and diagnose how a response is parsed and rendered.", + href: "/docs/openui-lang/developer-tools#debug", + icon: Bug, + }, + { + title: "Inspect", + description: "Monitor OpenUI streams, errors, and events in real time.", + href: "/docs/openui-lang/developer-tools#inspect", + icon: MagnifyingGlass, + }, + ], }, { - title: "Debug", - description: "Validate and stream OpenUI Lang.", - href: "/debug", - preview: { - light: "/nav/paste-light.webp", - dark: "/nav/paste-dark.webp", - }, + label: "Other", + children: [ + { + title: "Benchmarks", + description: "Compare how Generative UI frameworks perform across models", + href: "/benchmarks", + icon: PresentationChart, + }, + { + title: "Blogs", + description: + "Product updates, deep dives, and notes on building generative UI from our team.", + href: "/blog", + icon: Book, + }, + /* No /integrations route exists yet, here or on main. */ + { + title: "Integrations", + description: + "How OpenUI integrates easily with your AI frameworks, UI libraries, SDKs etc.", + href: "/integrations", + icon: PlugsConnected, + }, + ], }, ], }, + { title: "Documentation", href: "/docs", newTab: false }, + /* Deliberately the same destination as Resources > Research > Benchmarks. Up + here it is a promotion, badged while it is still new; down there it is + filed where someone browsing would look for it. Drop one of the two once + the launch is over. */ + { title: "Benchmarks", href: "/benchmarks", newTab: false, badge: "New" }, // Temporarily hidden — Agent Interface isn't ready to share yet. Restore when ready: // { title: "Agent Interface", href: "/agent-interface", newTab: false, badge: "New" }, - { title: "Benchmarks", href: "/benchmarks", newTab: false }, - { title: "Blogs", href: "/blog", newTab: false }, ]; +/* Either shape counts: a menu carries cards directly, or in labelled groups. A + leaf carries neither, and is the only kind with an href. */ export function isNavDropdown(item: NavItem): item is NavDropdown { - return "children" in item; + return "children" in item || "groups" in item; } +/* Hoisted: the layout effect reads this, and a fresh array each render would + make it a dependency and re-run the placement on every render. */ +const DROPDOWNS = PRIMARY_SITE_NAV_ITEMS.filter(isNavDropdown); + /* Panel geometry, mirrored from .viewportContent in the stylesheet. Kept in sync by hand because the natural width has to be known before layout. */ const CARD_TRACK = 232; const PANEL_GAP = 20; const PANEL_PADDING = 20; +/* Between two labelled groups, where the gap has to read as a division rather + than as the spacing between neighbouring cards. One step up from PANEL_GAP. */ +const GROUP_GAP = 24; /* Smallest gap left between the panel and either edge of the window. */ const VIEWPORT_MARGIN = 16; /* Forgiveness around the nav+panel box before the menu counts as left. */ @@ -141,17 +282,32 @@ const POINTER_SLACK = 12; closes. Long enough that crossing one on the way to a card doesn't count, short enough that settling on it closes right away. */ const NEIGHBOUR_DWELL_MS = 150; - -function DropdownCard({ child, onNavigate }: { child: NavDropdownChild; onNavigate: () => void }) { +/* How long the banner holds after the pointer leaves a row. Long enough that + crossing the gap between two rows never blanks it, and that leaving the menu + does not yank it away mid-read. Entering another row cancels the wait, so the + line swaps straight over rather than clearing first. */ +const BANNER_LINGER_MS = 400; + +/* What a row or card shows, independent of whether it is a link. */ +function renderBody(child: NavDropdownChild, layout: "cards" | "list") { return ( - - {child.preview && ( + <> + {layout === "list" && child.icon && ( + /* Both glyphs are mounted and the tile cross-fades between them, so the + swap costs no layout and nothing shifts under the pointer. Same arrow + split the cards use: up-right leaves the site, right stays on it. */ + + )} + {layout === "cards" && child.preview && ( {/* Both variants render; CSS reveals the one matching the theme, so there's no hydration flash on first paint. */} @@ -180,18 +336,79 @@ function DropdownCard({ child, onNavigate }: { child: NavDropdownChild; onNaviga {child.title} - + {/* No arrow: it would point at a destination that is not there yet. */} + {!child.comingSoon && ( + + )} - {child.description && ( + {layout === "cards" && child.description && ( {child.description} )} + {/* Under the copy rather than beside the title: it is the last thing to + read, once you know what the thing is. */} + {child.comingSoon && } + + ); +} + +/* The tag beside a title that is not yet a destination. */ +function ComingSoonTag() { + return Coming soon; +} + +function DropdownCard({ + child, + onNavigate, + layout = "cards", + onHover, +}: { + child: NavDropdownChild; + onNavigate: () => void; + layout?: "cards" | "list"; + onHover?: (child: NavDropdownChild | null) => void; +}) { + const className = + layout === "list" + ? styles.listItem + : /* A card with art shows hover by zooming it. One with none has + nothing to move, so it takes a fill instead. */ + `${styles.dropdownItem} ${child.preview ? "" : styles.dropdownItemPlain}`.trim(); + + /* Nothing to navigate to yet, so it is not a link: no href, no tab stop, and + none of the hover behaviour that would promise a click. */ + if (child.comingSoon) { + return ( + + {renderBody(child, layout)} + + ); + } + + return ( + onHover(child), + onPointerLeave: () => onHover(null), + /* Focus is the keyboard's version of resting on a row. */ + onFocus: () => onHover(child), + onBlur: () => onHover(null), + } + : {})} + {...(child.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})} + > + {renderBody(child, layout)} ); } @@ -209,9 +426,11 @@ function DropdownCard({ child, onNavigate }: { child: NavDropdownChild; onNaviga */ export function SitePrimaryNav() { const pathname = usePathname(); - const dropdowns = PRIMARY_SITE_NAV_ITEMS.filter(isNavDropdown); const [active, setActive] = useState(null); + /* The row under the pointer. Drives the banner, and with it the panel's + height: the layout effect re-measures whenever this changes. */ + const [hovered, setHovered] = useState(null); // Close on navigation. The link's own onClick covers the common case, but a // route change can also come from elsewhere (back/forward, a nested link), and @@ -229,6 +448,8 @@ export function SitePrimaryNav() { const triggerRefs = useRef>({}); const contentRefs = useRef>({}); + const bannerTimerRef = useRef(null); + const wasOpenRef = useRef(false); const watcherRef = useRef<((event: PointerEvent) => void) | null>(null); const neighbourTimerRef = useRef(null); @@ -238,6 +459,7 @@ export function SitePrimaryNav() { () => () => { if (watcherRef.current) document.removeEventListener("pointermove", watcherRef.current); if (neighbourTimerRef.current !== null) window.clearTimeout(neighbourTimerRef.current); + if (bannerTimerRef.current !== null) window.clearTimeout(bannerTimerRef.current); }, [], ); @@ -255,18 +477,32 @@ export function SitePrimaryNav() { const content = contentRefs.current[active]; const trigger = triggerRefs.current[active]; const nav = navRef.current; - if (!content || !trigger || !nav) return; + const item = DROPDOWNS.find((entry) => entry.title === active); + if (!content || !trigger || !nav || !item) return; const place = () => { const navRect = nav.getBoundingClientRect(); - const triggerRect = trigger.getBoundingClientRect(); const viewportWidth = document.documentElement.clientWidth; // Natural width is arithmetic, not measured: measuring would need the // content laid out unconstrained first, and it can't be — its columns are // sized from the width set below. - const columns = content.children.length; - const natural = PANEL_PADDING * 2 + columns * CARD_TRACK + PANEL_GAP * (columns - 1); + // + // Counted from the data rather than from content.children, which holds + // groups now: a grouped menu would otherwise be measured as if it had one + // card per group. Cards carry PANEL_GAP between them and GROUP_GAP where a + // group ends, which is why the two gaps are counted apart. + const cards = dropdownChildren(item).length; + const groups = dropdownColumns(item).length; + const track = item.cardTrack ?? CARD_TRACK; + // A list stacks its items, so each group is a single track wide however + // many it holds. Cards sit side by side, so every one of them takes one. + const tracks = item.layout === "list" ? groups : cards; + const natural = + PANEL_PADDING * 2 + + tracks * track + + (item.layout === "list" ? 0 : PANEL_GAP * (cards - groups)) + + GROUP_GAP * (groups - 1); const width = Math.min(natural, viewportWidth - VIEWPORT_MARGIN * 2); // Set the width first so the cards reflow, then read the height they @@ -274,12 +510,13 @@ export function SitePrimaryNav() { content.style.width = `${width}px`; const height = content.offsetHeight; - // Centre on the trigger, then push back inside the viewport if that would - // hang the panel off either edge. Once the panel is as wide as the space - // allows, both clamps meet and it sits centred on screen. - const centredOnTrigger = triggerRect.left + triggerRect.width / 2 - width / 2; + // Centred on the window, not on the trigger that opened it. Both panels are + // wide enough that hanging them off their own link only pushed them into an + // edge clamp, and a menu that lands in the same place every time reads as + // one surface rather than as a box chasing the pointer. + const centred = (viewportWidth - width) / 2; const left = Math.min( - Math.max(centredOnTrigger, VIEWPORT_MARGIN), + Math.max(centred, VIEWPORT_MARGIN), viewportWidth - width - VIEWPORT_MARGIN, ); @@ -309,7 +546,7 @@ export function SitePrimaryNav() { } return () => window.removeEventListener("resize", place); - }, [active]); + }, [active, hovered]); const stopWatchingPointer = () => { if (watcherRef.current) { @@ -325,10 +562,25 @@ export function SitePrimaryNav() { } }; + const cancelBannerClear = () => { + if (bannerTimerRef.current !== null) { + window.clearTimeout(bannerTimerRef.current); + bannerTimerRef.current = null; + } + }; + + const onRowHover = (child: NavDropdownChild | null) => { + cancelBannerClear(); + if (child) setHovered(child); + else bannerTimerRef.current = window.setTimeout(() => setHovered(null), BANNER_LINGER_MS); + }; + const close = () => { stopWatchingPointer(); cancelNeighbourClose(); + cancelBannerClear(); setActive(null); + setHovered(null); }; /** @@ -397,14 +649,23 @@ export function SitePrimaryNav() { > {PRIMARY_SITE_NAV_ITEMS.map((item) => { if (isNavDropdown(item)) { - const isActive = item.children.some((child) => pathname.startsWith(child.href)); + /* The home page is one of Product's cards, so a startsWith test on "/" + would light Product up on every route. An exact match for it, prefix + for the rest. */ + const isActive = dropdownChildren(item).some((child) => + child.href === "/" ? pathname === "/" : pathname.startsWith(child.href), + ); const isOpen = active === item.title; return (
setActive(item.title)} + onPointerEnter={() => { + setActive(item.title); + cancelBannerClear(); + setHovered(null); + }} >