"use client"
/**
* SecondaryNav — two-panel contextual navigation
*
* Layout:
* ┌──────┬──────────────────────────┐
* │ Rail │ Content panel │
* │ (52) │ (200–240px) │
* │ │ Section heading │
* │ ◉ │ · Nav item │
* │ ○ │ · Nav item (active) │
* │ ○ │ ───────────────── │
* │ │ Section heading │
* │ │ · Nav item │
* └──────┴──────────────────────────┘
*
* Usage:
*
*
* Or use composed pieces:
*
*
*/
import * as React from "react"
import { usePathname } from "@/lib/router-compat"
import { cn } from "@/lib/utils"
import { resolveActiveNavHref } from "@exxatdesignux/ui/lib/nav-active"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface SecondaryNavLink {
/** Unique key */
key: string
label: string
href: string
icon?: string
/** Badge count shown on link */
count?: number
/** If true, item is rendered as a section divider label (not a link) */
isSectionHeader?: boolean
}
export interface SecondaryNavSectionAction {
/** Tooltip / aria-label */
label: string
/** FontAwesome icon class, e.g. "fa-plus" */
icon: string
onClick: () => void
}
export interface SecondaryNavSection {
/** Unique key — used to identify the active section */
key: string
/** Tooltip shown on rail icon */
label: string
/** FontAwesome icon class, e.g. "fa-users" */
icon: string
/** Solid icon used when section is active */
iconActive?: string
/** Flat list of links (use isSectionHeader=true for dividers) */
links: SecondaryNavLink[]
/** When true, a search input is shown above the list and filters link labels */
searchable?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
/** Optional primary action rendered next to the section title (e.g. Add) */
action?: SecondaryNavSectionAction
}
// ─────────────────────────────────────────────────────────────────────────────
// RailButton — single icon in the narrow left rail
// ─────────────────────────────────────────────────────────────────────────────
function RailButton({
section,
isActive,
onClick,
}: {
section: SecondaryNavSection
isActive: boolean
onClick: () => void
}) {
return (
{section.label}
)
}
// ─────────────────────────────────────────────────────────────────────────────
// NavLink — single item in the content panel
// ─────────────────────────────────────────────────────────────────────────────
function NavLink({
link,
allLinkHrefs,
}: {
link: SecondaryNavLink
allLinkHrefs: readonly string[]
}) {
const pathname = usePathname()
const activeHref = resolveActiveNavHref(pathname, allLinkHrefs)
const isActive = activeHref !== null && activeHref === link.href
if (link.isSectionHeader) {
return (
{link.label}
)
}
return (
{link.icon && (
)}
{link.label}
{link.count !== undefined && (
{link.count}
)}
)
}
// ─────────────────────────────────────────────────────────────────────────────
// SecondaryNavRail — exported if consumers want to compose manually
// ─────────────────────────────────────────────────────────────────────────────
export function SecondaryNavRail({
sections,
activeSection,
onSectionChange,
className,
}: {
sections: SecondaryNavSection[]
activeSection: string
onSectionChange: (key: string) => void
className?: string
}) {
return (
)
}
// ─────────────────────────────────────────────────────────────────────────────
// SecondaryNavPanel — exported for manual composition
// ─────────────────────────────────────────────────────────────────────────────
export function SecondaryNavPanel({
section,
allLinkHrefs,
className,
}: {
section: SecondaryNavSection
/** All navigable hrefs (every section) so only one row is active at a time. */
allLinkHrefs: readonly string[]
className?: string
}) {
const [query, setQuery] = React.useState("")
const q = query.trim().toLowerCase()
const visibleLinks = React.useMemo(() => {
if (!section.searchable || !q) return section.links
// Filter out non-header items that don't match; drop headers whose group becomes empty
const kept: SecondaryNavLink[] = []
let pendingHeader: SecondaryNavLink | null = null
let groupHasMatch = false
for (const link of section.links) {
if (link.isSectionHeader) {
if (pendingHeader && groupHasMatch) kept.push(pendingHeader)
pendingHeader = link
groupHasMatch = false
continue
}
if (link.label.toLowerCase().includes(q)) {
if (pendingHeader && !groupHasMatch) {
kept.push(pendingHeader)
groupHasMatch = true
} else if (!pendingHeader) {
groupHasMatch = true
}
kept.push(link)
}
}
if (pendingHeader && groupHasMatch) {
// already pushed above when first match in group — nothing to do
}
return kept
}, [section.links, section.searchable, q])
return (
)
}
// ─────────────────────────────────────────────────────────────────────────────
// SecondaryNav — composed two-panel component (default export)
// ─────────────────────────────────────────────────────────────────────────────
export interface SecondaryNavProps {
sections: SecondaryNavSection[]
/** Which section key is active by default — defaults to first section */
defaultSection?: string
className?: string
/** Called when active section changes */
onSectionChange?: (key: string) => void
}
export function SecondaryNav({
sections,
defaultSection,
className,
onSectionChange,
}: SecondaryNavProps) {
const [activeSection, setActiveSection] = React.useState(
defaultSection ?? sections[0]?.key ?? ""
)
const currentSection = sections.find(s => s.key === activeSection) ?? sections[0]
const allLinkHrefs = React.useMemo(
() =>
sections.flatMap(s =>
s.links.filter(l => !l.isSectionHeader).map(l => l.href),
),
[sections],
)
function handleSectionChange(key: string) {
setActiveSection(key)
onSectionChange?.(key)
}
if (!currentSection) return null
return (
{/* Left icon rail — only shown when multiple sections */}
{sections.length > 1 && (
)}
{/* Right content panel */}
)
}