Files
NoteNextra-origin/components/navbar.client.tsx
2025-10-25 01:18:03 -05:00

189 lines
5.7 KiB
TypeScript

'use client'
import {
MenuItem as _MenuItem,
Menu,
MenuButton,
MenuItems
} from '@headlessui/react'
import cn from 'clsx'
import { Anchor, Button } from 'nextra/components'
import { useFSRoute } from 'nextra/hooks'
import { ArrowRightIcon, MenuIcon } from 'nextra/icons'
import type { MenuItem } from 'nextra/normalize-pages'
import type { FC, ReactNode } from 'react'
import { setMenu, useConfig, useMenu, useThemeConfig } from 'nextra-theme-docs'
import { usePathname } from 'next/navigation'
import { normalizePages } from 'nextra/normalize-pages'
import { PageMapItem } from 'nextra'
const classes = {
link: cn(
'x:text-sm x:contrast-more:text-gray-700 x:contrast-more:dark:text-gray-100 x:whitespace-nowrap',
'x:text-gray-600 x:hover:text-black x:dark:text-gray-400 x:dark:hover:text-gray-200',
'x:ring-inset x:transition-colors'
)
}
const NavbarMenu: FC<{
menu: MenuItem
children: ReactNode
}> = ({ menu, children }) => {
const routes = Object.fromEntries(
(menu.children || []).map(route => [route.name, route])
)
return (
<Menu>
<MenuButton
className={({ focus }) =>
cn(
classes.link,
'x:items-center x:flex x:gap-1.5 x:cursor-pointer',
focus && 'x:nextra-focus'
)
}
>
{children}
<ArrowRightIcon
height="14"
className="x:*:origin-center x:*:transition-transform x:*:rotate-90"
/>
</MenuButton>
<MenuItems
transition
className={cn(
'x:focus-visible:nextra-focus',
'nextra-scrollbar x:motion-reduce:transition-none',
// From https://headlessui.com/react/menu#adding-transitions
'x:origin-top x:transition x:duration-200 x:ease-out x:data-closed:scale-95 x:data-closed:opacity-0',
'x:border x:border-black/5 x:dark:border-white/20',
'x:z-30 x:rounded-md x:py-1 x:text-sm x:shadow-lg',
'x:backdrop-blur-md x:bg-nextra-bg/70',
// headlessui adds max-height as style, use !important to override
'x:max-h-[min(calc(100vh-5rem),256px)]!'
)}
anchor={{ to: 'bottom', gap: 10, padding: 16 }}
>
{Object.entries(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- fixme
(menu.items as Record<string, { title: string; href?: string }>) || {}
).map(([key, item]) => (
<_MenuItem
key={key}
as={Anchor}
href={item.href || routes[key]?.route}
className={({ focus }) =>
cn(
'x:block x:py-1.5 x:transition-colors x:ps-3 x:pe-9',
focus
? 'x:text-gray-900 x:dark:text-gray-100'
: 'x:text-gray-600 x:dark:text-gray-400'
)
}
>
{item.title}
</_MenuItem>
))}
</MenuItems>
</Menu>
)
}
const isMenu = (page: any): page is MenuItem => page.type === 'menu'
export const ClientNavbar: FC<{
pageMap: PageMapItem[]
children: ReactNode
className?: string
}> = ({ pageMap, children, className }) => {
const { topLevelNavbarItems } = normalizePages({
list: pageMap,
route: usePathname()
})
// filter out titles for elements in topLevelNavbarItems with non empty route
const existingCourseNames = new Set(
topLevelNavbarItems.filter(
item => !('href' in item)
).map(item => item.title)
)
console.log(existingCourseNames)
// filter out elements in topLevelNavbarItems with url but have title in existingCourseNames
const filteredTopLevelNavbarItems = topLevelNavbarItems.filter(item => !('href' in item && existingCourseNames.has(item.title)))
// const items = topLevelNavbarItems
// use filteredTopLevelNavbarItems to generate items
const items = filteredTopLevelNavbarItems
console.log(filteredTopLevelNavbarItems)
const themeConfig = useThemeConfig()
const pathname = useFSRoute()
const menu = useMenu()
return (
<>
<div
className={cn(
'x:flex x:gap-4 x:overflow-x-auto nextra-scrollbar x:py-1.5 x:max-md:hidden',
className
)}
>
{items.map((page, _index, arr) => {
if ('display' in page && page.display === 'hidden') return
if (isMenu(page)) {
return (
<NavbarMenu key={page.name} menu={page}>
{page.title}
</NavbarMenu>
)
}
const href =
// If it's a directory
('frontMatter' in page ? page.route : page.firstChildRoute) ||
page.href ||
page.route
const isCurrentPage =
href === pathname ||
(pathname.startsWith(page.route + '/') &&
arr.every(item => !('href' in item) || item.href !== pathname)) ||
undefined
return (
<Anchor
href={href}
key={page.name}
className={cn(
classes.link,
'x:aria-[current]:font-medium x:aria-[current]:subpixel-antialiased x:aria-[current]:text-current'
)}
aria-current={isCurrentPage}
>
{page.title}
</Anchor>
)
})}
</div>
{themeConfig.search && (
<div className="x:max-md:hidden">{themeConfig.search}</div>
)}
{children}
<Button
aria-label="Menu"
className={({ active }) =>
cn('nextra-hamburger x:md:hidden', active && 'x:bg-gray-400/20')
}
onClick={() => setMenu(prev => !prev)}
>
<MenuIcon height="24" className={cn({ open: menu })} />
</Button>
</>
)
}