diff --git a/src/components/LibrariesMenuContent.tsx b/src/components/LibrariesMenuContent.tsx new file mode 100644 index 000000000..0b81692aa --- /dev/null +++ b/src/components/LibrariesMenuContent.tsx @@ -0,0 +1,263 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { GridFourIcon } from '@phosphor-icons/react/GridFour' +import { ArrowRightIcon } from '@phosphor-icons/react/ArrowRight' +import { Button } from '~/components/ds/ui' +import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext' +import { publicLibraries, type LibrarySlim } from '~/libraries' +import { + categoryLabels, + categoryOrder, + categoryTextColor, + libraryCategories, + type LibraryCategory, +} from '~/libraries/categories' +import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' + +type IconComponent = React.ComponentType<{ className?: string }> + +function getLibraryDisplayName(library: LibrarySlim) { + return library.name.replace(/^TanStack\s+/, '') +} + +type LibraryMenuEntry = { + id: string + name: string + to: string + icon: IconComponent + /** `group-hover/lib:text-category-*` — recolors the icon to its category. */ + iconHoverColor: string +} + +// Full static class strings (Tailwind can't see composed names) mapping each +// category to the hover color applied to a library's icon in the mega-menu. +const categoryIconHoverColor: Record = { + framework: 'group-hover/lib:text-category-framework', + data: 'group-hover/lib:text-category-data', + ui: 'group-hover/lib:text-category-ui', + performance: 'group-hover/lib:text-category-performance', + tooling: 'group-hover/lib:text-category-tooling', +} + +type LibraryMenuColumn = { + category: LibraryCategory + label: string + colorClass: string + libraries: LibraryMenuEntry[] +} + +/** + * The Libraries mega-menu as five category columns (Framework, Data & State, + * UI, UX, Tooling), built from the canonical `libraryCategories` + * taxonomy. Iterating `libraryCategories` preserves the intended per-category + * order; only public, navigable libraries are shown. + */ +function getLibraryCategoryColumns(): LibraryMenuColumn[] { + const byCategory = new Map( + categoryOrder.map((category) => [category, []]), + ) + + for (const [id, category] of Object.entries(libraryCategories)) { + const library = publicLibraries.find((lib) => lib.id === id) + if (!library || !library.to) continue + byCategory.get(category)?.push({ + id: library.id, + name: getLibraryDisplayName(library), + to: library.to, + icon: libraryIcons[library.id] ?? fallbackLibraryIcon, + iconHoverColor: categoryIconHoverColor[category], + }) + } + + return categoryOrder + .map((category) => ({ + category, + label: categoryLabels[category], + colorClass: categoryTextColor[category], + libraries: byCategory.get(category) ?? [], + })) + .filter((column) => column.libraries.length > 0) +} + +export function LibrariesMenuContent({ + onNavigate, + variant, +}: { + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + const { openLibraries } = useLibrariesOverlay() + const columns = getLibraryCategoryColumns() + + const allLibraries = ( + + ) + + if (variant === 'mobile') { + return ( +
+ {columns.map((column) => ( + + ))} + {allLibraries} +
+ ) + } + + return ( +
+
+ {columns.map((column) => ( + + ))} +
+
+ {allLibraries} +
+
+ ) +} + +function LibraryCategoryColumn({ + column, + onNavigate, + variant, +}: { + column: LibraryMenuColumn + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + return ( +
+
+ {column.label} +
+
+ {column.libraries.map((library) => ( + + ))} +
+
+ ) +} + +function LibraryMenuRow({ + library, + onNavigate, + variant, +}: { + library: LibraryMenuEntry + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + const Icon = library.icon + const external = library.to.startsWith('http') + const className = twMerge( + // Light mode: an "elevated white" hover — a bright-white pill lifted off the + // glass with a soft shadow + hairline ring (contrast via depth, not value). + // Dark mode keeps the subtle white/4% (pressed 12%) overlay, no shadow/ring. + 'group/lib flex items-center gap-2 rounded-[14px] py-2 pl-[9px] pr-4 text-text-secondary transition-[color,background-color,box-shadow] hover:bg-white hover:text-text-primary hover:shadow-sm hover:ring-1 hover:ring-black/5 focus:bg-white focus:text-text-primary focus:shadow-sm focus:ring-1 focus:ring-black/5 focus:outline-none active:bg-white dark:hover:bg-text-primary/[0.04] dark:hover:shadow-none dark:hover:ring-0 dark:focus:bg-text-primary/[0.04] dark:focus:shadow-none dark:focus:ring-0 dark:active:bg-text-primary/[0.12]', + variant === 'desktop' + ? 'h-[38px] min-[1120px]:h-[46px] min-[1120px]:gap-2.5 min-[1120px]:rounded-[17px] min-[1120px]:pl-[11px] min-[1120px]:pr-[18px]' + : 'py-2.5', + ) + const content = ( + <> + {/* Plain template string: the category hover color is a `text-*` utility + and twMerge would drop it against a base color. */} + + + {library.name} + + + ) + + if (external) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index a58eaa3a8..a5d5de123 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,10 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' +const LazyLibrariesMenuContent = React.lazy(() => + import('./LibrariesMenuContent').then((module) => ({ + default: module.LibrariesMenuContent, + })), +) const LazyAiDock = React.lazy(() => import('./AiDock').then((m) => ({ default: m.AiDock })), ) @@ -15,7 +20,12 @@ const LazyMobileNavbarAuthControls = React.lazy(() => ) import { NavbarCartButton } from './NavbarCartButton' import { MegaMenuItem } from './MegaMenuItem' -import { Link, useLocation, useNavigate } from '@tanstack/react-router' +import { + CatchBoundary, + Link, + useLocation, + useNavigate, +} from '@tanstack/react-router' import { ArrowLeftIcon } from '@phosphor-icons/react/ArrowLeft' import { ArrowRightIcon } from '@phosphor-icons/react/ArrowRight' import { ArrowSquareOutIcon } from '@phosphor-icons/react/ArrowSquareOut' @@ -43,16 +53,8 @@ import { AiDockButton, SearchButton } from './SearchButton' import { BrandContextMenu } from './BrandContextMenu' import { useSearchContext } from '~/contexts/SearchContext' import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext' -import { findLibrary, publicLibraries, type LibrarySlim } from '~/libraries' -import { - categoryOf, - categoryLabels, - categoryOrder, - categoryTextColor, - libraryCategories, - type LibraryCategory, -} from '~/libraries/categories' -import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' +import { findLibrary } from '~/libraries' +import { categoryOf, categoryTextColor } from '~/libraries/categories' import { GithubIcon } from '~/components/icons/GithubIcon' import { Dropdown, @@ -377,69 +379,6 @@ const NAV_GROUPS = [ }, ] as const satisfies readonly NavMenuGroup[] -function getLibraryDisplayName(library: LibrarySlim) { - return library.name.replace(/^TanStack\s+/, '') -} - -type LibraryMenuEntry = { - id: string - name: string - to: string - icon: IconComponent - /** `group-hover/lib:text-category-*` — recolors the icon to its category. */ - iconHoverColor: string -} - -// Full static class strings (Tailwind can't see composed names) mapping each -// category to the hover color applied to a library's icon in the mega-menu. -const categoryIconHoverColor: Record = { - framework: 'group-hover/lib:text-category-framework', - data: 'group-hover/lib:text-category-data', - ui: 'group-hover/lib:text-category-ui', - performance: 'group-hover/lib:text-category-performance', - tooling: 'group-hover/lib:text-category-tooling', -} - -type LibraryMenuColumn = { - category: LibraryCategory - label: string - colorClass: string - libraries: LibraryMenuEntry[] -} - -/** - * The Libraries mega-menu as five category columns (Framework, Data & State, - * UI, UX, Tooling), built from the canonical `libraryCategories` - * taxonomy. Iterating `libraryCategories` preserves the intended per-category - * order; only public, navigable libraries are shown. - */ -function getLibraryCategoryColumns(): LibraryMenuColumn[] { - const byCategory = new Map( - categoryOrder.map((category) => [category, []]), - ) - - for (const [id, category] of Object.entries(libraryCategories)) { - const library = publicLibraries.find((lib) => lib.id === id) - if (!library || !library.to) continue - byCategory.get(category)?.push({ - id: library.id, - name: getLibraryDisplayName(library), - to: library.to, - icon: libraryIcons[library.id] ?? fallbackLibraryIcon, - iconHoverColor: categoryIconHoverColor[category], - }) - } - - return categoryOrder - .map((category) => ({ - category, - label: categoryLabels[category], - colorClass: categoryTextColor[category], - libraries: byCategory.get(category) ?? [], - })) - .filter((column) => column.libraries.length > 0) -} - function AiDockMount() { const { isAiDockOpen } = useSearchContext() const [hasActivated, setHasActivated] = React.useState(isAiDockOpen) @@ -823,6 +762,7 @@ function DesktopNavTrigger({ onDismiss: () => void onResetDismissed: () => void }) { + const [hasLoadedLibraries, setHasLoadedLibraries] = React.useState(false) const { openLibraries } = useLibrariesOverlay() const triggerClassName = twMerge( 'ts-mega-trigger inline-flex items-center gap-1 rounded-md px-2 py-2 text-xs font-medium min-[1120px]:gap-1.5 min-[1120px]:px-3 min-[1120px]:text-[13px]', @@ -838,8 +778,14 @@ function DesktopNavTrigger({ onAuxClick={(event) => { if (event.button === 1) onDismiss() }} + onPointerEnter={() => { + if (group.key === 'libraries') setHasLoadedLibraries(true) + }} onPointerLeave={onResetDismissed} - onFocusCapture={onResetDismissed} + onFocusCapture={() => { + onResetDismissed() + if (group.key === 'libraries') setHasLoadedLibraries(true) + }} > {group.key === 'libraries' ? ( - ) - - if (variant === 'mobile') { - return ( -
- {columns.map((column) => ( - - ))} - {allLibraries} -
- ) - } - - return ( -
-
- {columns.map((column) => ( - - ))} -
-
- {allLibraries} -
-
- ) -} - -function LibraryCategoryColumn({ - column, - onNavigate, - variant, -}: { - column: LibraryMenuColumn - onNavigate: () => void - variant: 'desktop' | 'mobile' -}) { - return ( -
-
- {column.label} -
-
- {column.libraries.map((library) => ( - - ))} -
-
- ) -} - -function LibraryMenuRow({ - library, - onNavigate, - variant, -}: { - library: LibraryMenuEntry - onNavigate: () => void - variant: 'desktop' | 'mobile' -}) { - const Icon = library.icon - const external = library.to.startsWith('http') - const className = twMerge( - // Light mode: an "elevated white" hover — a bright-white pill lifted off the - // glass with a soft shadow + hairline ring (contrast via depth, not value). - // Dark mode keeps the subtle white/4% (pressed 12%) overlay, no shadow/ring. - 'group/lib flex items-center gap-2 rounded-[14px] py-2 pl-[9px] pr-4 text-text-secondary transition-[color,background-color,box-shadow] hover:bg-white hover:text-text-primary hover:shadow-sm hover:ring-1 hover:ring-black/5 focus:bg-white focus:text-text-primary focus:shadow-sm focus:ring-1 focus:ring-black/5 focus:outline-none active:bg-white dark:hover:bg-text-primary/[0.04] dark:hover:shadow-none dark:hover:ring-0 dark:focus:bg-text-primary/[0.04] dark:focus:shadow-none dark:focus:ring-0 dark:active:bg-text-primary/[0.12]', - variant === 'desktop' - ? 'h-[38px] min-[1120px]:h-[46px] min-[1120px]:gap-2.5 min-[1120px]:rounded-[17px] min-[1120px]:pl-[11px] min-[1120px]:pr-[18px]' - : 'py-2.5', - ) - const content = ( - <> - {/* Plain template string: the category hover color is a `text-*` utility - and twMerge would drop it against a base color. */} - - - {library.name} - - - ) - - if (external) { - return ( - - {content} - - ) - } - - return ( - - {content} - - ) -} - function BlogMenuContent({ group, onNavigate, diff --git a/src/components/examples/ExampleWorkbench.client.tsx b/src/components/examples/ExampleWorkbench.client.tsx index cfdcfcb27..717d1d3b6 100644 --- a/src/components/examples/ExampleWorkbench.client.tsx +++ b/src/components/examples/ExampleWorkbench.client.tsx @@ -89,7 +89,7 @@ import { type ExampleDefinition, type ExampleWorkspace, } from '~/utils/example-workspace' -import { CodeMirrorEditor } from './CodeMirrorEditor.client' +import { LazyCodeMirrorEditor } from './LazyCodeMirrorEditor.client' import { MAX_SANDBOX_BROWSER_ANNOTATION_PROMPT_LENGTH, SandboxBrowser, @@ -3129,7 +3129,7 @@ export function ExampleWorkbench({ ))}
-
- + import('./CodeMirrorEditor.client').then((module) => ({ + default: module.CodeMirrorEditor, + })), +) + +function EditorLoadError() { + return ( +
+ Editor could not load. Reload the page to try again. +
+ ) +} + +export function LazyCodeMirrorEditor( + props: React.ComponentProps, +) { + const containerRef = React.useRef(null) + const inView = useInView(containerRef) + const [hasActivated, setHasActivated] = React.useState(false) + + React.useEffect(() => { + if (inView) setHasActivated(true) + }, [inView]) + + return ( +
+ {hasActivated || inView ? ( + + Loading editor… +
+ } + > + 'editor'} + errorComponent={EditorLoadError} + > + + + + ) : null} +
+ ) +}