From 432bef060124761fef543af60437b34308d07432 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 14 Jun 2026 03:14:52 -0600 Subject: [PATCH 01/17] Update site navigation --- src/components/Navbar.tsx | 1782 +++++++++++++++++-------- src/routes/-library-landing-route.tsx | 10 +- src/routes/workshops.tsx | 8 +- src/styles/app.css | 272 ++++ 4 files changed, 1500 insertions(+), 572 deletions(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 330ff4132..eaced105c 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -15,27 +15,33 @@ import { NavbarCartButton } from './NavbarCartButton' import { Link, useLocation, useMatches } from '@tanstack/react-router' import { NetlifyImage } from './NetlifyImage' import { + BookOpen, Code, - Users, - Music, + ExternalLink, + Grid2X2, + Hammer, + Heart, HelpCircle, - BookOpen, - TrendingUp, - Shirt, - ShieldCheck, + Mail, + Menu, + Newspaper, Paintbrush, - Hammer, + ShieldCheck, + Shirt, + Sparkles, + TrendingUp, User, - Menu, + Users, X, - Grid2X2, - Sparkles, } from 'lucide-react' import { ThemeToggle } from './ThemeToggle' import { AiDockButton, SearchButton } from './SearchButton' -import { libraries, SIDEBAR_LIBRARY_IDS, type LibrarySlim } from '~/libraries' -import { useClickOutside } from '~/hooks/useClickOutside' import { useSearchContext } from '~/contexts/SearchContext' +import { + librariesByGroup, + librariesGroupNamesMap, + type LibrarySlim, +} from '~/libraries' import { GithubIcon } from '~/components/icons/GithubIcon' import { Dropdown, @@ -48,98 +54,385 @@ import { InstagramIcon } from '~/components/icons/InstagramIcon' import { BSkyIcon } from '~/components/icons/BSkyIcon' import { BrandXIcon } from '~/components/icons/BrandXIcon' import { YouTubeIcon } from '~/components/icons/YouTubeIcon' - -import { Card } from '~/components/Card' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '~/components/Collapsible' +import { groupToSlug } from '~/components/stack/stack-categories' type LogoProps = { - showMenu: boolean - setShowMenu: React.Dispatch> - menuButtonRef: React.RefObject - title?: React.ComponentType | null + title?: React.ComponentType | null +} + +const LogoSection = ({ title }: LogoProps) => { + return ( + +
+ + + +
+
TanStack
+ + ) +} + +type IconComponent = React.ComponentType<{ className?: string }> + +type NavMenuKey = + | 'libraries' + | 'learn' + | 'community' + | 'tools' + | 'merch' + | 'support' + +type MegaMenuDirection = 'left' | 'right' | 'down' +type MegaMenuPanePhase = 'enter' | 'exit' | 'current' + +type MegaMenuPane = { + id: number + key: NavMenuKey + phase: MegaMenuPanePhase + direction: MegaMenuDirection +} + +type MegaMenuLayout = { + left: number + width: number +} + +type NavMenuItem = { + label: string + to: string + hash?: string + description?: string + badge?: string + icon?: IconComponent } -const LogoSection = ({ - showMenu, - setShowMenu, - menuButtonRef, - title, -}: LogoProps) => { - const pointerInsideButtonRef = React.useRef(false) - const toggleMenu = () => { - setShowMenu((prev) => !prev) +type NavMenuSection = { + label: string + items: readonly NavMenuItem[] +} + +type NavMenuGroup = { + key: NavMenuKey + label: string + to?: string + sections: readonly NavMenuSection[] + rail?: { + eyebrow: string + title: string + description: string + item: NavMenuItem } +} + +type NavigationLibrary = LibrarySlim & { + to: string +} + +type LibraryGroupId = keyof typeof librariesByGroup + +const DESKTOP_NAV_CLASS = 'hidden min-[1024px]:flex' +const MOBILE_NAV_CLASS = 'min-[1024px]:hidden' +const CLOSE_DELAY_MS = 140 +const MEGA_MENU_TRANSITION_MS = 400 +const MEGA_MENU_PANEL_CLOSE_MS = 180 +const MEGA_MENU_MAX_WIDTH = 1120 +const MEGA_MENU_MIN_ALIGNED_WIDTH = 960 +const MEGA_MENU_VIEWPORT_PADDING = 16 +const MEGA_MENU_ORDER: Record = { + libraries: 0, + learn: 1, + community: 2, + tools: 3, + merch: 4, + support: 5, +} +const LIBRARY_MENU_GROUP_IDS: readonly LibraryGroupId[] = [ + 'framework', + 'state', + 'headlessUI', + 'performance', + 'tooling', +] + +const NAV_GROUPS = [ + { + key: 'libraries', + label: 'Libraries', + to: '/libraries', + sections: [], + rail: { + eyebrow: 'Browse', + title: 'All TanStack libraries', + description: + 'Filter the ecosystem by framework and find the right package faster.', + item: { + label: 'All Libraries', + to: '/libraries', + icon: Grid2X2, + }, + }, + }, + { + key: 'learn', + label: 'Learn', + sections: [ + { + label: 'Resources', + items: [ + { + label: 'Blog', + to: '/blog', + description: 'Release notes, architecture notes, and essays.', + icon: Newspaper, + }, + { + label: 'YouTube', + to: 'https://youtube.com/@tan_stack', + description: 'The official TanStack channel.', + icon: YouTubeIcon, + }, + ], + }, + ], + rail: { + eyebrow: 'Workshops', + title: 'Learn from maintainers', + description: + 'Remote and in-person TanStack workshops for teams that need depth.', + item: { + label: 'Professional Workshops', + to: '/workshops', + icon: Users, + }, + }, + }, + { + key: 'community', + label: 'Community', + sections: [ + { + label: 'Channels', + items: [ + { + label: 'Discord', + to: 'https://tlinz.com/discord', + description: 'Community support and real-time discussion.', + icon: DiscordIcon, + }, + { + label: 'GitHub', + to: 'https://github.com/TanStack', + description: 'Source, issues, discussions, and releases.', + icon: GithubIcon, + }, + ], + }, + { + label: 'People & Work', + items: [ + { + label: 'Maintainers', + to: '/maintainers', + description: 'Meet the people maintaining the stack.', + icon: Code, + }, + { + label: 'Contributors', + to: '/maintainers', + description: 'Core, library, and community contributors.', + icon: Users, + }, + { + label: 'Showcase', + to: '/showcase', + description: 'Products and teams building with TanStack.', + icon: Sparkles, + }, + ], + }, + ], + }, + { + key: 'tools', + label: 'Tools', + sections: [ + { + label: 'Tools', + items: [ + { + label: 'Builder', + to: '/builder', + description: 'Generate TanStack app starters.', + badge: 'Alpha', + icon: Hammer, + }, + { + label: 'Stats', + to: '/stats/npm', + description: 'NPM and ecosystem usage data.', + icon: TrendingUp, + }, + ], + }, + ], + }, + { + key: 'merch', + label: 'Merch', + sections: [ + { + label: 'Shop', + items: [ + { + label: 'New Apparel', + to: '/merch', + description: 'TanStack shirts, hoodies, and new drops.', + icon: Shirt, + }, + ], + }, + ], + }, + { + key: 'support', + label: 'Support', + sections: [ + { + label: 'Support', + items: [ + { + label: 'Support Overview', + to: '/support', + description: 'Find the right support path.', + icon: HelpCircle, + }, + { + label: 'Partners', + to: '/partners', + description: 'Companies supporting TanStack.', + icon: Heart, + }, + { + label: 'OSS Sponsors', + to: '/', + hash: 'sponsors', + description: 'Sponsors keeping TanStack open source.', + icon: ShieldCheck, + }, + { + label: 'Enterprise Support', + to: '/paid-support', + description: 'Private consulting and expert support.', + icon: Users, + }, + { + label: 'Contact', + to: 'mailto:support@tanstack.com', + description: 'Get in touch with the TanStack team.', + icon: Mail, + }, + ], + }, + { + label: 'About', + items: [ + { + label: 'Ethos', + to: '/ethos', + description: 'How we think about open source and products.', + icon: ShieldCheck, + }, + { + label: 'Tenets', + to: '/tenets', + description: 'The values that shape TanStack libraries.', + icon: BookOpen, + }, + { + label: 'Brand Guide', + to: '/brand-guide', + description: 'Logos, colors, and brand usage.', + icon: Paintbrush, + }, + ], + }, + ], + rail: { + eyebrow: 'Partners', + title: 'Work with TanStack', + description: 'Sponsorships, placements, and partner pages.', + item: { + label: 'Partnership Inquiry', + to: 'mailto:partners@tanstack.com?subject=TanStack Partnership Inquiry', + icon: Mail, + }, + }, + }, +] as const satisfies readonly NavMenuGroup[] + +function isNavigationLibrary( + library: LibrarySlim, +): library is NavigationLibrary { return ( - <> - - -
- - - -
-
TanStack
- - + typeof library.to === 'string' && + library.to.startsWith('/') && + library.visible !== false ) } -const MobileCard = ({ - children, - isActive, -}: { - children: React.ReactNode - isActive?: boolean -}) => ( - - {children} - -) +function getLibraryDisplayName(library: LibrarySlim) { + return library.name.replace(/^TanStack\s+/, '') +} + +function isExternalLink(to: string) { + return to.startsWith('http') || to.startsWith('mailto:') +} + +function getLibraryDocsTo(library: NavigationLibrary) { + return `${library.to}/latest/docs` +} + +function getMenuGroup(key: NavMenuKey) { + return NAV_GROUPS.find((group) => group.key === key) +} + +function getLibraryMenuGroups() { + return LIBRARY_MENU_GROUP_IDS.map((groupId) => { + const groupLibraries = librariesByGroup[groupId] + const libraries = groupLibraries.filter(isNavigationLibrary) + + return { + id: groupId, + label: librariesGroupNamesMap[groupId], + libraries, + } + }).filter((group) => group.libraries.length > 0) +} function AiDockMount() { const { isAiDockOpen } = useSearchContext() @@ -164,6 +457,7 @@ function AiDockMount() { export function Navbar({ children }: { children: React.ReactNode }) { const matches = useMatches() + const location = useLocation() const { Title } = React.useMemo(() => { const match = [...matches].reverse().find((m) => m.staticData.Title) @@ -174,6 +468,10 @@ export function Navbar({ children }: { children: React.ReactNode }) { }, [matches]) const containerRef = React.useRef(null) + const primaryNavRef = React.useRef(null) + const megaMenuRef = React.useRef(null) + const closeTimerRef = React.useRef(undefined) + const activeMenuKeyRef = React.useRef(null) React.useEffect(() => { const updateContainerHeight = () => { @@ -186,7 +484,7 @@ export function Navbar({ children }: { children: React.ReactNode }) { } } - updateContainerHeight() // Initial call to set the height + updateContainerHeight() window.addEventListener('resize', updateContainerHeight) return () => { @@ -194,10 +492,126 @@ export function Navbar({ children }: { children: React.ReactNode }) { } }, []) - const [showMenu, setShowMenu] = React.useState(false) + const [activeMenuKey, setActiveMenuKey] = React.useState( + null, + ) + const [megaMenuDirection, setMegaMenuDirection] = + React.useState('down') + const [megaMenuLayout, setMegaMenuLayout] = React.useState({ + left: MEGA_MENU_VIEWPORT_PADDING, + width: MEGA_MENU_MAX_WIDTH, + }) + const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false) const [canLoadAuthControls, setCanLoadAuthControls] = React.useState(false) - const largeMenuRef = React.useRef(null) - const menuButtonRef = React.useRef(null) + + React.useEffect(() => { + document.documentElement.classList.add('ts-nav-hydrated') + + return () => { + document.documentElement.classList.remove('ts-nav-hydrated') + } + }, []) + + const closeMegaMenu = React.useCallback(() => { + activeMenuKeyRef.current = null + setActiveMenuKey(null) + }, []) + + const cancelMegaMenuClose = React.useCallback(() => { + if (closeTimerRef.current !== undefined) { + window.clearTimeout(closeTimerRef.current) + closeTimerRef.current = undefined + } + }, []) + + const scheduleMegaMenuClose = React.useCallback(() => { + cancelMegaMenuClose() + closeTimerRef.current = window.setTimeout(() => { + closeMegaMenu() + }, CLOSE_DELAY_MS) + }, [cancelMegaMenuClose, closeMegaMenu]) + + const updateMegaMenuLayout = React.useCallback(() => { + if (typeof window === 'undefined') { + return + } + + const trigger = + primaryNavRef.current?.querySelector('.ts-mega-trigger') + const viewportWidth = window.innerWidth + const availableWidth = Math.max( + 0, + viewportWidth - MEGA_MENU_VIEWPORT_PADDING * 2, + ) + const triggerLeft = Math.round( + trigger?.getBoundingClientRect().left ?? MEGA_MENU_VIEWPORT_PADDING, + ) + const alignedWidth = Math.min( + MEGA_MENU_MAX_WIDTH, + Math.max(0, viewportWidth - triggerLeft - MEGA_MENU_VIEWPORT_PADDING), + ) + const nextLayout = + alignedWidth >= MEGA_MENU_MIN_ALIGNED_WIDTH + ? { + left: triggerLeft, + width: alignedWidth, + } + : { + left: MEGA_MENU_VIEWPORT_PADDING, + width: availableWidth, + } + + setMegaMenuLayout((previousLayout) => { + if ( + previousLayout.left === nextLayout.left && + previousLayout.width === nextLayout.width + ) { + return previousLayout + } + + return nextLayout + }) + }, []) + + const openMegaMenu = React.useCallback( + (key: NavMenuKey) => { + cancelMegaMenuClose() + updateMegaMenuLayout() + const previousKey = activeMenuKeyRef.current + + if (previousKey && previousKey !== key) { + setMegaMenuDirection( + MEGA_MENU_ORDER[key] > MEGA_MENU_ORDER[previousKey] + ? 'right' + : 'left', + ) + } else if (!previousKey) { + setMegaMenuDirection('down') + } + + activeMenuKeyRef.current = key + setActiveMenuKey(key) + }, + [cancelMegaMenuClose, updateMegaMenuLayout], + ) + + React.useEffect(() => { + if (!activeMenuKey) { + return + } + + updateMegaMenuLayout() + window.addEventListener('resize', updateMegaMenuLayout) + + return () => { + window.removeEventListener('resize', updateMegaMenuLayout) + } + }, [activeMenuKey, updateMegaMenuLayout]) + + React.useEffect(() => { + closeMegaMenu() + setMobileMenuOpen(false) + }, [closeMegaMenu, location.pathname, location.hash]) React.useEffect(() => { if (typeof window === 'undefined') { @@ -226,12 +640,53 @@ export function Navbar({ children }: { children: React.ReactNode }) { } }, []) - // Close mobile menu when clicking outside - const smallMenuRef = useClickOutside({ - enabled: showMenu, - onClickOutside: () => setShowMenu(false), - additionalRefs: [largeMenuRef, menuButtonRef], - }) + React.useEffect(() => { + if (!activeMenuKey && !mobileMenuOpen) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeMegaMenu() + setMobileMenuOpen(false) + } + } + + const onPointerDown = (event: PointerEvent) => { + if (!activeMenuKey) { + return + } + + const target = event.target + + if (!(target instanceof Node)) { + return + } + + if ( + containerRef.current?.contains(target) || + megaMenuRef.current?.contains(target) + ) { + return + } + + closeMegaMenu() + } + + document.addEventListener('keydown', onKeyDown) + document.addEventListener('pointerdown', onPointerDown) + + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('pointerdown', onPointerDown) + } + }, [activeMenuKey, closeMegaMenu, mobileMenuOpen]) + + React.useEffect(() => { + return () => { + cancelMegaMenuClose() + } + }, [cancelMegaMenuClose]) const loginButtonFallback = ( -
+
- - } - > + }> - + {Title ? ( @@ -287,8 +728,32 @@ export function Navbar({ children }: { children: React.ReactNode }) {
) : null}
+ +
-
+ +
{socialLinks}
@@ -305,477 +770,674 @@ export function Navbar({ children }: { children: React.ReactNode }) { loginButtonFallback )}
+
) - const activeLibrary = useLocation({ - select: (location) => { - return libraries.find((library) => { - return library.to && location.pathname.startsWith(library.to) - }) - }, - }) - - const linkClasses = `flex items-center justify-between gap-2 group px-3 py-3 md:px-2 md:py-1 rounded-lg hover:bg-gray-500/10 font-bold text-base md:text-sm` - - const items = ( -
-
- {(() => { - return libraries - .filter( - ( - d, - ): d is LibrarySlim & { - to: string - textStyle: string - badge?: string - colorFrom: string - } => - d.to !== undefined && - d.visible !== false && - (SIDEBAR_LIBRARY_IDS as readonly string[]).includes(d.id), - ) - .sort((a, b) => { - const indexA = SIDEBAR_LIBRARY_IDS.indexOf( - a.id as (typeof SIDEBAR_LIBRARY_IDS)[number], - ) - const indexB = SIDEBAR_LIBRARY_IDS.indexOf( - b.id as (typeof SIDEBAR_LIBRARY_IDS)[number], - ) - return indexA - indexB - }) - })().map((library, i) => { - const [_, name] = library.name.split(' ') - const isActive = library.to === activeLibrary?.to - - return ( -
- {library.to?.startsWith('http') ? ( - <> - {/* Mobile: Card wrapper */} - - - - {name} - - - {/* Desktop: no card */} - - - {name} - - - ) : ( - <> - {/* Mobile: Direct link with Card */} - - - - - {name} - - {library.badge ? ( - - {library.badge} - - ) : null} - - - {/* Desktop: Simple link */} - - - - {name} - - {library.badge ? ( - - {library.badge} - - ) : null} - - - )} -
- ) - })} - {/* Mobile: More Libraries card */} - - -
- -
More Libraries
-
- -
- {/* Desktop: More Libraries link */} - -
- -
More Libraries
-
- -
-
+ const desktopMegaMenu = ( +
+
{ + if (event.pointerType === 'touch') return + cancelMegaMenuClose() + }} + onPointerLeave={(event) => { + if (event.pointerType === 'touch') return + scheduleMegaMenuClose() + }} + > + +
+
+ ) + + const mobileMenu = mobileMenuOpen ? ( +
+
+
+ +
+ +
+ {socialLinks}
- {/* Mobile separator */} -
-
+
+ ) : null + + return ( + <> + {navbar} + {desktopMegaMenu} + {mobileMenu} +