Skip to content
Open
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
84 changes: 69 additions & 15 deletions www/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Separator } from "@/registry/ui/separator"
import { GitHubIcon } from "@/components/icons/github"
import { HeaderActionsSlot } from "@/components/layout/header-slot"
import { Logo } from "@/components/layout/logo"
import { MobileNav } from "@/components/layout/mobile-nav"
import { ProgressiveBlur } from "@/components/progressive-blur"
import { SearchCommand } from "@/components/search-command"
import { ThemeToggle } from "@/components/theme-toggle"
Expand Down Expand Up @@ -85,36 +84,91 @@ export function Header({ className, items = [] }: HeaderProps) {
</nav>
</div>
<div className="flex items-center gap-0.5">
{/* One dialog, two triggers: RAC's DialogTrigger reaches pressables via
context, so the desktop search icon and the mobile menu button share
the same search surface — its empty state already lists the full
navigation, GitHub and the theme toggle, so mobile needs nothing else. */}
<SearchCommand keyboardShortcut items={items}>
<Button variant="quiet" isIconOnly aria-label="Search docs">
<Button
variant="quiet"
isIconOnly
aria-label="Search docs"
className="max-lg:hidden"
>
<SearchIcon />
</Button>
{/* Linear-style two-bar menu icon; no hover/press fill — those
states don't exist on touch. Bars sit at the center and shift
apart when closed; on open they collapse back and rotate into an
X (Linear's 160ms ease-out-quad). */}
<Button
variant="quiet"
isIconOnly
aria-label="Menu"
className="hover:bg-transparent lg:hidden pressed:bg-transparent"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden
className="**:[rect]:origin-center **:[rect]:transition-transform **:[rect]:duration-[160ms] **:[rect]:ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
>
<rect
x="1"
y="7.5"
width="14"
height="1"
rx="0.5"
className="-translate-y-[3.5px] group-aria-expanded/button:translate-y-0 group-aria-expanded/button:rotate-45"
/>
<rect
x="1"
y="7.5"
width="14"
height="1"
rx="0.5"
className="translate-y-[3.5px] group-aria-expanded/button:translate-y-0 group-aria-expanded/button:-rotate-45"
/>
</svg>
</Button>
</SearchCommand>
<a
aria-label="GitHub"
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
data-icon-only=""
className={buttonStyles({ variant: "quiet", isIconOnly: true })}
className={buttonStyles({
variant: "quiet",
isIconOnly: true,
className: "max-lg:hidden",
})}
>
<GitHubIcon />
</a>
<ThemeToggle variant="quiet" isIconOnly />
<ThemeToggle variant="quiet" isIconOnly className="max-lg:hidden" />
<HeaderActionsSlot />
{/* On /studio the slot above carries studio-specific actions instead. */}
{/* On /studio the slot above carries studio-specific actions instead.
Desktop-only: studio is a desktop tool, and mobile keeps the navbar
to logo + menu — Studio stays reachable from the menu drawer. */}
{pathname !== "/studio" && (
<LinkButton
href="/studio"
variant="primary"
size="sm"
className="ml-1.5"
>
Open studio
</LinkButton>
<>
<Separator
orientation="vertical"
className="mx-1.5 h-4 max-lg:hidden"
/>
<LinkButton
href="/studio"
variant="primary"
size="sm"
className="max-lg:hidden"
>
Open studio
</LinkButton>
</>
)}
<Separator orientation="vertical" className="mx-1.5 h-4 lg:hidden" />
<MobileNav items={items} />
</div>
</header>
)
Expand Down
122 changes: 0 additions & 122 deletions www/src/components/layout/mobile-nav.tsx

This file was deleted.

98 changes: 59 additions & 39 deletions www/src/components/search-command.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react"
import type * as PageTree from "fumadocs-core/page-tree"

import { Responsive } from "@/registry/lib/responsive"
import { Dialog, DialogContent } from "@/registry/ui/dialog"
import { Drawer, DrawerHandle } from "@/registry/ui/drawer"
import {
Expand All @@ -22,6 +21,22 @@ const loadSearchDialog = () =>
SearchDialog = module.default
})

// The header swaps its trigger at lg (1024px): below it the hamburger is the
// only way in, so the drawer branch must cover the same range. The registry
// useIsMobile splits at 768px, which would hand 768–1023px an autofocused
// desktop modal instead.
function useIsBelowLg() {
const [isBelow, setIsBelow] = React.useState(false)
React.useEffect(() => {
const mql = window.matchMedia("(max-width: 1023px)")
const onChange = () => setIsBelow(mql.matches)
mql.addEventListener("change", onChange)
onChange()
return () => mql.removeEventListener("change", onChange)
}, [])
return isBelow
}

interface SearchCommandProps {
items: PageTree.Node[]
keyboardShortcut?: boolean
Expand All @@ -34,6 +49,7 @@ export function SearchCommand({
children,
}: SearchCommandProps) {
const [isOpen, setIsOpen] = React.useState(false)
const isMobile = useIsBelowLg()

// A cold open (first ⌘K, nothing warmed) waits for the chunk — a few ms,
// since the open request itself starts the fetch — instead of flashing the
Expand Down Expand Up @@ -84,44 +100,48 @@ export function SearchCommand({
>
{children}
</span>
{/* Modal on desktop, Drawer on mobile; content remounts on open so the search resets. */}
<Responsive
render={(isMobile) => {
const content = (
<DialogContent
aria-label="Search documentation"
className="flex flex-col gap-0 overflow-hidden p-0! max-md:min-h-0 max-md:flex-1"
>
{SearchDialog && (
<SearchDialog items={items} onClose={() => setIsOpen(false)} />
)}
</DialogContent>
)
return isMobile ? (
// Match the desktop modal's raised surface. Near-full-height sheet
// (mirrors base-ui.com's mobile search): the input sits at the top,
// structurally clear of the iOS keyboard, and the results list
// flexes below it. The drawer's keyboard inset keeps the list's
// bottom above the keyboard.
<Drawer className="h-[calc(100dvh_-_3rem_+_var(--drawer-bleed))] bg-(--neutral-100)">
<DrawerHandle />
{content}
</Drawer>
) : (
// Composed (not <Modal>) so the panel AND backdrop appear
// instantly — duration-0 on both. Mirror shadcn.com: max-w-lg
// (512px), top-15%.
<ModalOverlay>
<ModalBackdrop className="duration-0 group-exiting/modal:duration-0" />
<ModalViewport>
<ModalPanel className="mt-[15vh] self-start duration-0 [--modal-background:var(--neutral-100)] [--modal-radius:var(--radius-2xl)] sm:max-w-lg entering:scale-100 exiting:scale-100">
{content}
</ModalPanel>
</ModalViewport>
</ModalOverlay>
)
}}
/>
{/* Modal on desktop, Drawer below lg; content remounts on open so the search resets. */}
{(() => {
const content = (
<DialogContent
aria-label="Search documentation"
className="flex flex-col gap-0 overflow-hidden p-0! max-lg:min-h-0 max-lg:flex-1"
>
{SearchDialog && (
<SearchDialog
items={items}
// The drawer doubles as the nav menu — opening with the
// keyboard up would punish browse intent.
autoFocus={!isMobile}
onClose={() => setIsOpen(false)}
/>
)}
</DialogContent>
)
return isMobile ? (
// Match the desktop modal's raised surface. Near-full-height sheet
// (mirrors base-ui.com's mobile search): the input sits at the top,
// structurally clear of the iOS keyboard, and the results list
// flexes below it. The drawer's keyboard inset keeps the list's
// bottom above the keyboard.
<Drawer className="h-[calc(100dvh_-_3rem_+_var(--drawer-bleed))] bg-(--neutral-100)">
<DrawerHandle />
{content}
</Drawer>
) : (
// Composed (not <Modal>) so the panel AND backdrop appear
// instantly — duration-0 on both. Mirror shadcn.com: max-w-lg
// (512px), top-15%.
<ModalOverlay>
<ModalBackdrop className="duration-0 group-exiting/modal:duration-0" />
<ModalViewport>
<ModalPanel className="mt-[15vh] self-start duration-0 [--modal-background:var(--neutral-100)] [--modal-radius:var(--radius-2xl)] sm:max-w-lg entering:scale-100 exiting:scale-100">
{content}
</ModalPanel>
</ModalViewport>
</ModalOverlay>
)
})()}
</Dialog>
)
}
Loading
Loading