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
71 changes: 45 additions & 26 deletions src/components/docs/DocsHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, Search } from 'lucide-react'

type Heading = {
id: string
Expand All @@ -13,9 +13,16 @@ type Heading = {
type DocsHeaderProps = {
sidebarOpen?: boolean
onSidebarToggle?: () => void
onOpenSearch?: () => void
searchOpen?: boolean
}

export default function DocsHeader({ sidebarOpen, onSidebarToggle }: DocsHeaderProps = {}) {
export default function DocsHeader({
sidebarOpen,
onSidebarToggle,
onOpenSearch,
searchOpen,
}: DocsHeaderProps = {}) {
const pathname = usePathname()
const [tocOpen, setTocOpen] = useState(false)
const [headings, setHeadings] = useState<Heading[]>([])
Expand Down Expand Up @@ -45,20 +52,20 @@ export default function DocsHeader({ sidebarOpen, onSidebarToggle }: DocsHeaderP
}
}, [])

// Keep header visible when sidebar is open
// Keep header visible when sidebar or docs search is open
useEffect(() => {
if (sidebarOpen) {
if (sidebarOpen || searchOpen) {
setIsVisible(true)
}
}, [sidebarOpen])
}, [sidebarOpen, searchOpen])

// Show/hide header on scroll (mobile only)
useEffect(() => {
if (typeof window === 'undefined') return

const handleScroll = () => {
// Always show header if TOC is open or sidebar is open
if (tocOpen || sidebarOpen) {
// Always show header if TOC, sidebar, or docs search is open
if (tocOpen || sidebarOpen || searchOpen) {
setIsVisible(true)
return
}
Expand All @@ -79,7 +86,7 @@ export default function DocsHeader({ sidebarOpen, onSidebarToggle }: DocsHeaderP

window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [lastScrollY, tocOpen, sidebarOpen])
}, [lastScrollY, tocOpen, sidebarOpen, searchOpen])

// Extract headings from the main content
useEffect(() => {
Expand Down Expand Up @@ -181,25 +188,37 @@ export default function DocsHeader({ sidebarOpen, onSidebarToggle }: DocsHeaderP
<div className="relative overflow-hidden bg-white/60 dark:bg-gray-900/60 rounded-2xl border border-gray-200/50 dark:border-white/5 backdrop-blur-xl backdrop-saturate-150 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/40 via-transparent to-transparent dark:from-white/5 dark:via-transparent dark:to-transparent pointer-events-none" />
<div className="relative flex items-center justify-between gap-2 py-1 px-4">
{/* Left side: Docs sidebar toggle button */}
{onSidebarToggle && (
<button
onClick={onSidebarToggle}
className="flex-shrink-0 p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-gray-800/60 transition-all duration-200 active:scale-95"
aria-label={sidebarOpen ? 'Close docs navigation' : 'Open docs navigation'}
aria-expanded={sidebarOpen}
>
<div className={`docs-sidebar-icon ${sidebarOpen ? 'open' : ''}`}>
<div className="docs-sidebar-panel"></div>
<div className="docs-sidebar-lines">
<div className="docs-sidebar-line"></div>
<div className="docs-sidebar-line"></div>
<div className="docs-sidebar-line"></div>
<div className="relative flex items-center gap-2 py-1 px-4">
<div className="flex items-center gap-2 shrink-0">
{onSidebarToggle && (
<button
type="button"
onClick={onSidebarToggle}
className="flex-shrink-0 p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-gray-800/60 transition-all duration-200 active:scale-95"
aria-label={sidebarOpen ? 'Close docs navigation' : 'Open docs navigation'}
aria-expanded={sidebarOpen}
>
<div className={`docs-sidebar-icon ${sidebarOpen ? 'open' : ''}`}>
<div className="docs-sidebar-panel"></div>
<div className="docs-sidebar-lines">
<div className="docs-sidebar-line"></div>
<div className="docs-sidebar-line"></div>
<div className="docs-sidebar-line"></div>
</div>
</div>
</div>
</button>
)}
</button>
)}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="flex-shrink-0 p-1.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100/60 dark:hover:bg-gray-800/60 transition-all duration-200 active:scale-95"
aria-label="Search documentation"
>
<Search className="w-[1.15rem] h-[1.15rem]" strokeWidth={2} aria-hidden />
</button>
)}
</div>

{/* Center: Current section name (clickable to toggle TOC) */}
{headings.length > 0 && (
Expand Down
14 changes: 11 additions & 3 deletions src/components/docs/DocsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useRef, createContext, useContext } from 'react'
import { usePathname } from 'next/navigation'
import DocsSidebar from './DocsSidebar'
import DocsSearchPalette from './DocsSearchPalette'
import DocsTOC from './DocsTOC'
import DocsHeader from './DocsHeader'

Expand All @@ -11,6 +12,7 @@ export const DisableAnimationsContext = createContext(false)
export const useDisableAnimations = () => useContext(DisableAnimationsContext)

export default function DocsLayout({ children }: { children: React.ReactNode }) {
const [docsSearchOpen, setDocsSearchOpen] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [isVisible, setIsVisible] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
Expand Down Expand Up @@ -108,7 +110,13 @@ export default function DocsLayout({ children }: { children: React.ReactNode })

return (
<DisableAnimationsContext.Provider value={true}>
<DocsHeader sidebarOpen={sidebarOpen} onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
<DocsSearchPalette open={docsSearchOpen} onOpenChange={setDocsSearchOpen} />
<DocsHeader
sidebarOpen={sidebarOpen}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
onOpenSearch={() => setDocsSearchOpen(true)}
searchOpen={docsSearchOpen}
/>
<div className="min-h-screen bg-theme-primary pt-[6.5rem] lg:pt-24">
<div className="max-w-[1280px] 2xl:max-w-[1440px] mx-auto px-4 lg:px-6 py-8 lg:py-10 w-full">
{/* Mobile sidebar toggle button - hidden, now integrated into DocsHeader */}
Expand Down Expand Up @@ -152,12 +160,12 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
}}
>
<h2 id="mobile-sidebar-title" className="sr-only">Documentation navigation</h2>
<DocsSidebar />
<DocsSidebar onOpenSearch={() => setDocsSearchOpen(true)} />
</aside>

{/* Desktop sidebar - always visible, scrollable with styled scrollbar */}
<aside className="hidden lg:block w-64 shrink-0 sticky top-24 self-start max-h-[calc(100vh-8rem)]">
<DocsSidebar />
<DocsSidebar onOpenSearch={() => setDocsSearchOpen(true)} />
</aside>

{/* Overlay for mobile when sidebar is open */}
Expand Down
211 changes: 211 additions & 0 deletions src/components/docs/DocsSearchPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
'use client'

import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Search } from 'lucide-react'
import { docsNavSections } from '@/lib/docs/docs-nav'

type FlatItem = {
sectionTitle: string
title: string
href: string
}

function flattenNav(): FlatItem[] {
return docsNavSections.flatMap((section) =>
section.items.map((item) => ({
sectionTitle: section.title,
title: item.title,
href: item.href,
}))
)
}

function matchesQuery(item: FlatItem, q: string): boolean {
const trimmed = q.trim().toLowerCase()
if (!trimmed) return true
const hay = `${item.sectionTitle} ${item.title.replace(/`/g, '')} ${item.href}`.toLowerCase()
const tokens = trimmed.split(/\s+/).filter(Boolean)
return tokens.every((t) => hay.includes(t))
}

const MAX_RESULTS = 40

export default function DocsSearchPalette({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)

const allItems = useMemo(() => flattenNav(), [])
const results = useMemo(() => {
const matched = allItems.filter((item) => matchesQuery(item, query))
return matched.slice(0, MAX_RESULTS)
}, [allItems, query])

const safeIndex =
results.length === 0 ? 0 : Math.min(activeIndex, results.length - 1)

useEffect(() => {
setActiveIndex(0)
}, [query])

useEffect(() => {
if (!open) return
setQuery('')
setActiveIndex(0)
const id = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(id)
}, [open])

useEffect(() => {
if (!open || !listRef.current) return
const row = listRef.current.querySelector<HTMLElement>(`[data-index="${safeIndex}"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [open, safeIndex])

useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return
if (open) {
e.preventDefault()
onOpenChange(false)
return
}
const t = e.target as HTMLElement | null
if (t?.closest('[contenteditable="true"]')) return
const tag = t?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
e.preventDefault()
onOpenChange(true)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onOpenChange])

useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onOpenChange(false)
return
}
if (results.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((i) => Math.min(results.length - 1, i + 1))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((i) => Math.max(0, i - 1))
return
}
if (e.key === 'Enter') {
e.preventDefault()
const item = results[Math.min(activeIndex, results.length - 1)]
if (item) {
onOpenChange(false)
router.push(item.href)
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, results, activeIndex, onOpenChange, router])

const goTo = (href: string) => {
onOpenChange(false)
router.push(href)
}

if (!open) return null

return (
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-[15vh] px-4 sm:px-6">
<button
type="button"
className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-[2px]"
aria-label="Close search"
onClick={() => onOpenChange(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="docs-search-title"
className="relative w-full max-w-lg rounded-xl border border-theme-primary bg-theme-primary shadow-2xl overflow-hidden"
>
<h2 id="docs-search-title" className="sr-only">
Search documentation
</h2>
<div className="flex items-center gap-2 border-b border-theme-primary px-3 py-2.5">
<Search className="w-4 h-4 shrink-0 text-theme-muted" aria-hidden />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search docs…"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="flex-1 min-w-0 bg-transparent text-sm text-theme-primary placeholder:text-theme-muted focus:outline-none"
/>
<kbd className="hidden sm:inline text-[10px] text-theme-muted font-sans px-1.5 py-0.5 rounded border border-theme-primary bg-theme-secondary">
Esc
</kbd>
</div>
<div
ref={listRef}
className="max-h-[min(50vh,320px)] overflow-y-auto py-1 sidebar-scrollable"
>
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-theme-tertiary">
{query.trim() ? 'No pages match.' : 'Type to filter docs.'}
</p>
) : (
<ul className="space-y-0.5 px-1 pb-1">
{results.map((item, index) => {
const active = index === safeIndex
return (
<li key={`${item.href}-${item.title}`}>
<button
type="button"
data-index={index}
onClick={() => goTo(item.href)}
onMouseEnter={() => setActiveIndex(index)}
className={`w-full text-left rounded-lg px-2.5 py-2 text-sm transition-colors ${
active
? 'bg-blue-50 text-blue-900 dark:bg-blue-900/35 dark:text-blue-100'
: 'text-theme-primary hover:bg-theme-secondary'
}`}
>
<span className="block font-medium leading-snug">{item.title}</span>
<span className="block text-xs text-theme-tertiary mt-0.5">
{item.sectionTitle}
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
<p className="px-3 py-2 text-[11px] text-theme-muted border-t border-theme-primary">
<span className="hidden sm:inline">⌘K / Ctrl+K</span>
<span className="sm:hidden">Keyboard shortcuts</span>
{' · '}
Page titles and sidebar only
</p>
</div>
</div>
)
}
Loading
Loading