"use client"
import * as React from "react"
import { Link, useNavigate } from "react-router"
import { cn } from "@/lib/utils"
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Tip } from "@/components/ui/tip"
import { useScrollStuck } from "@/hooks/use-scroll-stuck"
import { FILTER_LIST_SEARCH_THRESHOLD } from "@exxatdesignux/ui/lib/filter-list-search-threshold"
import { useRowFitLadder } from "@exxatdesignux/ui/hooks/use-row-fit-ladder"
// The page `
` type, shared with `PageHeader` so a record switcher title and a
// plain title are the same size on sibling routes.
import { PAGE_TITLE_TYPE_CLASS } from "@/components/page-header"
export interface PageBreadcrumbMenuOption {
id: string
label: string
href?: string
onSelect?: () => void
selected?: boolean
}
export interface PageBreadcrumbTrailItem {
label: string
href?: string
/** Peer options — renders a switcher instead of a static link or label. */
menu?: PageBreadcrumbMenuOption[]
menuAriaLabel?: string
}
export interface PageBreadcrumbBackProps {
/** Destination label (e.g. "Question hub") — spoken on the icon, not shown at rest. */
label: string
href: string
/** Current page title — shown after the back icon once the page scrolls. */
scrollTitle?: string
/** Peer record switcher for the title (same menu as breadcrumb leaf / PageHeader). */
scrollTitleMenu?: PageBreadcrumbMenuOption[]
scrollTitleMenuAriaLabel?: string
className?: string
}
export interface PageBreadcrumbTrailProps {
/** Linkable ancestors (e.g. Question hub). */
items?: PageBreadcrumbTrailItem[]
/**
* Final segment in the trail. Omit when the current page is the `PageHeader`
* `
` — use ancestors-only above the title (no duplicate label).
*/
currentPage?: string
/** Switch among peer records on the current page segment (detail routes). */
currentPageMenu?: PageBreadcrumbMenuOption[]
currentPageMenuAriaLabel?: string
/**
* Where the trail sits: `header` (SiteHeader / utility bar, ancestors +
* `currentPage`) or `content` (ancestors only, above a `PageHeader` title).
*
* Reaches the DOM as `data-trail-variant` and nothing else. How much of the
* trail shows is measured from the room the trail is given, so both places
* collapse on the same evidence rather than on a count picked per variant.
*/
variant?: "header" | "content"
className?: string
}
function BreadcrumbMenuOptionMarker({ selected }: { selected?: boolean }) {
return selected ? (
) : (
)
}
function RecordMenuTriggerButton({
label,
menuAriaLabel,
isCurrentPage,
variant = "breadcrumb",
className,
...props
}: React.ComponentProps<"button"> & {
label: string
menuAriaLabel?: string
isCurrentPage?: boolean
variant?: "breadcrumb" | "title"
}) {
return (
)
}
function RecordMenuSegment({
label,
menu,
menuAriaLabel,
isCurrentPage = false,
variant = "breadcrumb",
}: {
label: string
menu: PageBreadcrumbMenuOption[]
menuAriaLabel?: string
isCurrentPage?: boolean
variant?: "breadcrumb" | "title"
}) {
const navigate = useNavigate()
const searchable = menu.length > FILTER_LIST_SEARCH_THRESHOLD
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
React.useEffect(() => {
if (!open) setSearch("")
}, [open])
const filteredMenu = React.useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return menu
return menu.filter(option => option.label.toLowerCase().includes(query))
}, [menu, search])
const selectOption = React.useCallback(
(option: PageBreadcrumbMenuOption) => {
if (option.href) navigate(option.href)
else option.onSelect?.()
setOpen(false)
},
[navigate],
)
if (searchable) {
return (
` record switcher — same search/popover menu as breadcrumbs, sized for
* `PageHeader` titles on detail routes.
*/
export function PageTitleRecordSwitcher(
props: Omit, "variant" | "isCurrentPage">,
) {
return (
)
}
function BreadcrumbTrailSegment({
crumb,
isCurrentPage = false,
}: {
crumb: Pick
isCurrentPage?: boolean
}) {
if (crumb.menu?.length) {
return (
)
}
if (isCurrentPage) {
return (
{crumb.label}
)
}
if (crumb.href) {
return (
{crumb.label}
)
}
return (
{crumb.label}
)
}
/**
* Single-step back nav — back icon + parent destination (no chevron trail).
* Use in `SiteHeader` for focused child routes (composer, wizard) where the
* page `
` is the current title.
*/
export function PageBreadcrumbBack({
label,
href,
scrollTitle,
scrollTitleMenu,
scrollTitleMenuAriaLabel,
className,
}: PageBreadcrumbBackProps) {
const isStuck = useScrollStuck()
const showPageTitle = Boolean(scrollTitle) && isStuck
return (
{showPageTitle ? (
{scrollTitleMenu?.length ? (
) : (
{scrollTitle}
)}
) : null}
)
}
/**
* Product breadcrumb trail — one component for SiteHeader and in-page shells.
* Uses shadcn `Breadcrumb` primitives with Exxat site-header typography.
*
* Every segment keeps its **text label** (never a generic house icon — the first
* crumb is often a hub like Library, not Home).
*
* The trail collapses **only when it runs out of room**, measured, so a page
* three levels deep in a wide window reads Dashboard → Design system → Tokens
* rather than hiding a parent it had space for. When the row is short the middle
* segments move into More (`BreadcrumbEllipsis`), shallowest first, and the
* current page truncates last.
*
* For back-icon + parent label only, use {@link PageBreadcrumbBack}.
*/
const EMPTY_BREADCRUMB_ITEMS: PageBreadcrumbTrailItem[] = []
type TrailSegment = {
crumb: Pick
isCurrentPage?: boolean
key: string
}
/** Name of the control holding the segments the row could not fit. */
const BREADCRUMB_MORE_LABEL = "More breadcrumbs"
function BreadcrumbCollapsedMore({
segments,
}: {
segments: TrailSegment[]
}) {
if (segments.length === 0) return null
return (
{/* Icon-only, so the name is spoken and shown: `Tip` beside `aria-label`,
same pairing as the tab row's overflow trigger. */}
{segments.map(segment => (
{segment.crumb.href != null ? (
{segment.crumb.label}
) : (
{segment.crumb.label}
)}
))}
)
}
/**
* Which segments the row can afford, given how many the measured ladder says
* fit. Order of sacrifice, from a trail like Dashboard → Design system →
* Components → Toggle switch:
*
* 1. The **root** and the **current page** are kept as long as anything is.
* The root is the trail's anchor; the leaf is where you are.
* 2. Everything between them goes into More, **shallowest first** — the
* segments nearest the root are the ones a deep page is least likely to
* need, and the immediate parent is the one it is most likely to want.
* 3. Below two slots even the root goes in, leaving More + the current page.
*
* Nothing hides while the trail fits. `fits` counts segments, so it never lands
* on a shape that shows More next to a hidden list of nothing.
*/
function planTrail(segments: TrailSegment[], fits: number) {
type Entry =
| { type: "segment"; segment: TrailSegment }
| { type: "more"; segments: TrailSegment[] }
if (fits >= segments.length) {
return segments.map(segment => ({ type: "segment", segment }))
}
const last = segments[segments.length - 1]!
const keepRoot = fits >= 2 && segments.length > 1
/** Ancestors kept next to the leaf, deepest first, after root and leaf. */
const keptParents = Math.max(0, fits - (keepRoot ? 2 : 1))
const parents = segments.slice(keepRoot ? 1 : 0, -1)
const shown = keptParents > 0 ? parents.slice(-keptParents) : []
const hidden = keptParents > 0 ? parents.slice(0, -keptParents) : parents
return [
...(keepRoot ? [{ type: "segment", segment: segments[0]! } as Entry] : []),
...(hidden.length > 0 ? [{ type: "more", segments: hidden } as Entry] : []),
...shown.map(segment => ({ type: "segment", segment })),
{ type: "segment", segment: last } as Entry,
]
}
function renderTrailSegments(segments: TrailSegment[], fits: number) {
const entries = planTrail(segments, fits)
/**
* The leaf holds a floor while an ancestor could still step aside for it.
*
* Without one the row can never report overflow: ancestors do not shrink, so
* a tight row squeezes the leaf instead, and a leaf that squeezes to nothing
* keeps `scrollWidth` inside the viewport no matter how long the trail gets.
* The ladder would then read every trail as fitting. The floor turns that
* squeeze into the overflow it really is.
*
* Once nothing is left to shed the floor has no one to signal, so it lifts and
* the leaf goes back to truncating rather than being clipped by the trail.
*/
const leafHoldsFloor = fits > 1 && segments.length > 1
return entries.map((entry, i) => (
{entry.type === "more" ? (
) : (
)}
{i < entries.length - 1 ? (
) : null}
))
}
export function PageBreadcrumbTrail({
items = EMPTY_BREADCRUMB_ITEMS,
currentPage,
currentPageMenu,
currentPageMenuAriaLabel,
variant = "content",
className,
}: PageBreadcrumbTrailProps) {
const trailItems = items ?? EMPTY_BREADCRUMB_ITEMS
const segments = React.useMemo(() => {
const next: TrailSegment[] = trailItems.map((crumb, i) => ({
crumb,
key: `ancestor-${crumb.label}-${i}`,
}))
if (currentPage != null) {
next.push({
key: `current-${currentPage}`,
isCurrentPage: true,
crumb: {
label: currentPage,
menu: currentPageMenu,
menuAriaLabel: currentPageMenuAriaLabel,
},
})
}
return next
}, [trailItems, currentPage, currentPageMenu, currentPageMenuAriaLabel])
// Same ladder the tab rows use, minus its label rung: a crumb is a label, so
// it has no icon to fall back to and can only step into More.
const listRef = React.useRef(null)
const { visible: fits } = useRowFitLadder({
viewportRef: listRef,
count: segments.length,
canCollapse: false,
enabled: true,
})
return (
{renderTrailSegments(segments, fits)}
)
}