Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
377 changes: 377 additions & 0 deletions src/components/DocsHeader.tsx
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">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep navbar-only sections reachable on small screens

This header now hides all navItems below the lg breakpoint (max-lg:hidden), but support and changelog are still filtered out of sidebar tabs in src/routes/$.tsx via NAVBAR_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 👍 / 👎.

{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>
);
}
4 changes: 0 additions & 4 deletions src/lib/layout.shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ export function baseOptions(): BaseLayoutProps {
),
},
links: [
{
text: "Integrations",
url: buildDocsPath("integrations"),
},
{
text: "Changelog",
url: buildDocsPath("changelog"),
Expand Down
Loading
Loading