-
Notifications
You must be signed in to change notification settings - Fork 3
feat: improve navigation #203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,377 @@ | ||
| "use client"; | ||
|
|
||
| import { cn } from "@/lib/cn"; | ||
| import { buttonVariants } from "fumadocs-ui/components/ui/button"; | ||
| import { Popover, PopoverContent, PopoverTrigger } from "fumadocs-ui/components/ui/popover"; | ||
| import { | ||
| isLayoutTabActive, | ||
| LinkItem, | ||
| type LayoutTab, | ||
| type LinkItemType, | ||
| } from "fumadocs-ui/layouts/shared"; | ||
| import { useNotebookLayout } from "fumadocs-ui/layouts/notebook"; | ||
| import { usePathname } from "fumadocs-core/framework"; | ||
| import Link from "fumadocs-core/link"; | ||
| import { ChevronDown, Languages, Sidebar } from "lucide-react"; | ||
| import { Fragment, useMemo, useRef, useState, type ComponentProps, type PointerEvent } from "react"; | ||
|
|
||
| const SDK_TAB_LABELS: Record<string, string> = { | ||
| ios: "iOS", | ||
| android: "Android", | ||
| expo: "Expo", | ||
| flutter: "Flutter", | ||
| unity: "Unity", | ||
| "react-native": "React Native", | ||
| community: "Community", | ||
| }; | ||
|
|
||
| const SDK_TAB_SLUGS = new Set(Object.keys(SDK_TAB_LABELS)); | ||
|
|
||
| function getTabPrefix(url: string) { | ||
| return url.replace(/^\/docs\//, "").split("/")[0] ?? ""; | ||
| } | ||
|
|
||
| function isSdkTab(tab: LayoutTab) { | ||
| return SDK_TAB_SLUGS.has(getTabPrefix(tab.url)); | ||
| } | ||
|
|
||
| function getSdkTabLabel(tab: LayoutTab) { | ||
| const prefix = getTabPrefix(tab.url); | ||
| return SDK_TAB_LABELS[prefix] ?? String(tab.title); | ||
| } | ||
|
|
||
| function getSdkTabDescription(tab: LayoutTab) { | ||
| return tab.description; | ||
| } | ||
|
|
||
| export function DocsHeader(props: ComponentProps<"header">) { | ||
| const { | ||
| slots, | ||
| navItems, | ||
| isNavTransparent, | ||
| props: { tabMode, nav, tabs, sidebar }, | ||
| } = useNotebookLayout(); | ||
| const { open } = slots.sidebar?.useSidebar?.() ?? {}; | ||
| const navMode = nav?.mode ?? "auto"; | ||
| const sidebarCollapsible = sidebar.collapsible ?? true; | ||
| const showLayoutTabs = tabMode === "navbar" && tabs.length > 0; | ||
|
|
||
| if (nav?.component) return nav.component; | ||
|
|
||
| return ( | ||
| <header | ||
| id="nd-subnav" | ||
| data-transparent={isNavTransparent && !open} | ||
| {...props} | ||
| className={cn( | ||
| "sticky [grid-area:header] flex flex-col top-(--fd-docs-row-1) z-10 backdrop-blur-sm transition-colors data-[transparent=false]:bg-fd-background/80 layout:[--fd-header-height:--spacing(14)]", | ||
| showLayoutTabs && "lg:layout:[--fd-header-height:--spacing(24)]", | ||
| props.className, | ||
| )} | ||
| > | ||
| <div data-header-body className="flex border-b px-4 gap-2 h-14 md:px-6"> | ||
| <div | ||
| className={cn( | ||
| "items-center", | ||
| navMode === "top" && "flex flex-1", | ||
| navMode === "auto" && "hidden has-data-[collapsed=true]:md:flex max-md:flex", | ||
| )} | ||
| > | ||
| {sidebarCollapsible && slots.sidebar && navMode === "auto" && ( | ||
| <slots.sidebar.collapseTrigger | ||
| className={cn( | ||
| buttonVariants({ color: "ghost", size: "icon-sm" }), | ||
| "-ms-1.5 text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden", | ||
| )} | ||
| > | ||
| <Sidebar /> | ||
| </slots.sidebar.collapseTrigger> | ||
| )} | ||
| {slots.navTitle && ( | ||
| <slots.navTitle | ||
| className={cn( | ||
| "inline-flex items-center gap-2.5 font-semibold", | ||
| navMode === "auto" && "md:hidden", | ||
| )} | ||
| /> | ||
| )} | ||
| {nav?.children} | ||
| </div> | ||
|
|
||
| {slots.searchTrigger && ( | ||
| <slots.searchTrigger.full | ||
| hideIfDisabled | ||
| className={cn( | ||
| "w-full my-auto max-md:hidden", | ||
| navMode === "top" ? "ps-2.5 rounded-xl max-w-sm" : "max-w-[240px]", | ||
| )} | ||
| /> | ||
| )} | ||
|
|
||
| <div className="flex flex-1 items-center justify-end md:gap-2"> | ||
| <div className="flex items-center gap-6 empty:hidden max-lg:hidden"> | ||
| {navItems | ||
| .filter((item) => item.type !== "icon") | ||
| .map((item, i) => ( | ||
| <NavbarLinkItem item={item} key={i} /> | ||
| ))} | ||
| </div> | ||
| {navItems | ||
| .filter((item) => item.type === "icon") | ||
| .map((item, i) => ( | ||
| <LinkItem | ||
| item={item} | ||
| className={cn( | ||
| buttonVariants({ size: "icon-sm", color: "ghost" }), | ||
| "text-fd-muted-foreground max-lg:hidden", | ||
| )} | ||
| aria-label={item.label} | ||
| key={i} | ||
| > | ||
| {item.icon} | ||
| </LinkItem> | ||
| ))} | ||
| <div className="flex items-center md:hidden"> | ||
| {slots.searchTrigger && <slots.searchTrigger.sm hideIfDisabled className="p-2" />} | ||
| {slots.sidebar && ( | ||
| <slots.sidebar.trigger | ||
| className={cn(buttonVariants({ color: "ghost", size: "icon-sm", className: "p-2 -me-1.5" }))} | ||
| > | ||
| <Sidebar /> | ||
| </slots.sidebar.trigger> | ||
| )} | ||
| </div> | ||
| <div className="flex items-center gap-2 max-md:hidden"> | ||
| {slots.languageSelect && ( | ||
| <slots.languageSelect.root> | ||
| <Languages className="size-4.5 text-fd-muted-foreground" /> | ||
| </slots.languageSelect.root> | ||
| )} | ||
| {slots.themeSwitch && <slots.themeSwitch />} | ||
| {sidebarCollapsible && slots.sidebar && navMode === "top" && ( | ||
| <slots.sidebar.collapseTrigger | ||
| className={cn( | ||
| buttonVariants({ color: "secondary", size: "icon-sm" }), | ||
| "text-fd-muted-foreground rounded-full -me-1.5", | ||
| )} | ||
| > | ||
| <Sidebar /> | ||
| </slots.sidebar.collapseTrigger> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {showLayoutTabs && ( | ||
| <LayoutHeaderTabs | ||
| data-header-tabs | ||
| className="overflow-x-auto border-b px-6 h-10 max-lg:hidden" | ||
| tabs={tabs} | ||
| /> | ||
| )} | ||
| </header> | ||
| ); | ||
| } | ||
|
|
||
| function LayoutHeaderTabs({ | ||
| tabs, | ||
| className, | ||
| ...props | ||
| }: ComponentProps<"div"> & { tabs: LayoutTab[] }) { | ||
| const pathname = usePathname(); | ||
| const selectedIdx = useMemo( | ||
| () => tabs.findLastIndex((option) => isLayoutTabActive(option, pathname)), | ||
| [tabs, pathname], | ||
| ); | ||
| const sdkTabs = tabs.filter(isSdkTab); | ||
|
|
||
| return ( | ||
| <div className={cn("flex flex-row items-end gap-6", className)} {...props}> | ||
| {sdkTabs.length > 0 && <SdkTabsDropdown tabs={sdkTabs} pathname={pathname} />} | ||
| {tabs.map((option, i) => { | ||
| if (isSdkTab(option)) return null; | ||
|
|
||
| const { title, url, unlisted, props: { className, ...rest } = {} } = option; | ||
| const isSelected = selectedIdx === i; | ||
| const icon = option.props?.children ?? option.icon; | ||
| return ( | ||
| <Link | ||
| href={url} | ||
| className={cn( | ||
| "inline-flex border-b-2 border-transparent transition-colors items-center pb-1.5 font-medium gap-2 text-fd-muted-foreground text-sm text-nowrap hover:text-fd-accent-foreground", | ||
| unlisted && !isSelected && "hidden", | ||
| isSelected && "border-fd-primary text-fd-primary", | ||
| className, | ||
| )} | ||
| {...rest} | ||
| key={i} | ||
| > | ||
| <span className="hidden size-4 shrink-0 items-center justify-center [&_svg]:!size-4 lg:inline-flex"> | ||
| {icon} | ||
| </span> | ||
| {title} | ||
| </Link> | ||
| ); | ||
| })} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function SdkTabsDropdown({ tabs, pathname }: { tabs: LayoutTab[]; pathname: string }) { | ||
| const [open, setOpen] = useState(false); | ||
| const selected = tabs.findLast((tab) => isLayoutTabActive(tab, pathname)); | ||
| const isSelected = Boolean(selected); | ||
| const selectedIcon = selected?.props?.children ?? selected?.icon; | ||
|
|
||
| return ( | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger | ||
| className={cn( | ||
| "inline-flex items-center gap-2 text-sm font-medium text-nowrap text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground focus-visible:outline-none", | ||
| isSelected && "text-fd-primary", | ||
| )} | ||
| > | ||
| <span className="border-b-2 border-transparent pb-1.5 text-fd-muted-foreground">SDK</span> | ||
| {selected && ( | ||
| <span className="inline-flex items-center gap-2 border-b-2 border-fd-primary pb-1.5 text-fd-primary"> | ||
| <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:!size-4"> | ||
| {selectedIcon} | ||
| </span> | ||
| {getSdkTabLabel(selected)} | ||
| </span> | ||
| )} | ||
| <span className="inline-flex border-b-2 border-transparent pb-1.5"> | ||
| <ChevronDown className={cn("size-3 transition-transform", open && "rotate-180")} /> | ||
| </span> | ||
| </PopoverTrigger> | ||
| <PopoverContent className="min-w-52 bg-fd-card p-1 text-fd-card-foreground shadow-lg text-start"> | ||
| {tabs.map((tab) => { | ||
| const active = isLayoutTabActive(tab, pathname); | ||
| const icon = tab.props?.children ?? tab.icon; | ||
| const description = getSdkTabDescription(tab); | ||
| return ( | ||
| <Link | ||
| href={tab.url} | ||
| className={cn( | ||
| "flex items-center gap-2 rounded-md p-2 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground", | ||
| active && "text-fd-primary", | ||
| )} | ||
| onClick={() => setOpen(false)} | ||
| key={tab.url} | ||
| > | ||
| <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:!size-4"> | ||
| {icon} | ||
| </span> | ||
| <span className="flex min-w-0 flex-col gap-1"> | ||
| <span className="leading-none">{getSdkTabLabel(tab)}</span> | ||
| {description && ( | ||
| <span className="text-xs leading-none text-fd-muted-foreground"> | ||
| {description} | ||
| </span> | ||
| )} | ||
| </span> | ||
| </Link> | ||
| ); | ||
| })} | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| } | ||
|
|
||
| function NavbarLinkItem({ | ||
| item, | ||
| className, | ||
| ...props | ||
| }: ComponentProps<"a"> & { item: LinkItemType }) { | ||
| if (item.type === "custom") return item.children; | ||
| if (item.type === "menu") return <NavbarLinkItemMenu item={item} className={className} {...props} />; | ||
|
|
||
| return ( | ||
| <LinkItem | ||
| item={item} | ||
| className={cn( | ||
| "text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground data-[active=true]:text-fd-primary", | ||
| className, | ||
| )} | ||
| {...props} | ||
| > | ||
| {item.text} | ||
| </LinkItem> | ||
| ); | ||
| } | ||
|
|
||
| function NavbarLinkItemMenu({ | ||
| item, | ||
| hoverDelay = 50, | ||
| className, | ||
| ...props | ||
| }: ComponentProps<"button"> & { | ||
| item: Extract<LinkItemType, { type: "menu" }>; | ||
| hoverDelay?: number; | ||
| }) { | ||
| const [open, setOpen] = useState(false); | ||
| const timeoutRef = useRef<number | null>(null); | ||
| const freezeUntil = useRef<number | null>(null); | ||
| const delaySetOpen = (value: boolean) => { | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = null; | ||
| } | ||
| timeoutRef.current = window.setTimeout(() => { | ||
| setOpen(value); | ||
| freezeUntil.current = Date.now() + 300; | ||
| }, hoverDelay); | ||
| }; | ||
| const onPointerEnter = (e: PointerEvent) => { | ||
| if (e.pointerType === "touch") return; | ||
| delaySetOpen(true); | ||
| }; | ||
| const onPointerLeave = (e: PointerEvent) => { | ||
| if (e.pointerType === "touch") return; | ||
| delaySetOpen(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <Popover | ||
| open={open} | ||
| onOpenChange={(value) => { | ||
| if (freezeUntil.current === null || Date.now() >= freezeUntil.current) setOpen(value); | ||
| }} | ||
| > | ||
| <PopoverTrigger | ||
| className={cn( | ||
| "inline-flex items-center gap-1.5 p-1 text-sm text-fd-muted-foreground transition-colors has-data-[active=true]:text-fd-primary data-[state=open]:text-fd-accent-foreground focus-visible:outline-none", | ||
| className, | ||
| )} | ||
| onPointerEnter={onPointerEnter} | ||
| onPointerLeave={onPointerLeave} | ||
| {...props} | ||
| > | ||
| {item.url ? <LinkItem item={item}>{item.text}</LinkItem> : item.text} | ||
| <ChevronDown className="size-3" /> | ||
| </PopoverTrigger> | ||
| <PopoverContent | ||
| className="flex flex-col p-1 text-fd-muted-foreground text-start" | ||
| onPointerEnter={onPointerEnter} | ||
| onPointerLeave={onPointerLeave} | ||
| > | ||
| {item.items.map((child, i) => { | ||
| if (child.type === "custom") return <Fragment key={i}>{child.children}</Fragment>; | ||
| return ( | ||
| <LinkItem | ||
| item={child} | ||
| className="inline-flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4" | ||
| onClick={() => { | ||
| if ("ontouchstart" in window || navigator.maxTouchPoints > 0) setOpen(false); | ||
| }} | ||
| key={i} | ||
| > | ||
| {child.icon} | ||
| {child.text} | ||
| </LinkItem> | ||
| ); | ||
| })} | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This header now hides all
navItemsbelow thelgbreakpoint (max-lg:hidden), butsupportandchangelogare still filtered out of sidebar tabs insrc/routes/$.tsxviaNAVBAR_ONLY_TABS. On mobile/tablet widths, users can no longer navigate to those sections from the docs navigation UI (they’re neither in tabs nor in visible header links), which is a functional regression for those routes.Useful? React with 👍 / 👎.