All components

SEO Breadcrumb Nav

Accessible breadcrumb navigation backed by GET /v1/seo/breadcrumb. Watches lng/lat and renders linked crumbs when base_url is set.

Live preview

No breadcrumb for this point.

Install

$bunx shadcn@latest add https://mapbase.dev/r/seo-breadcrumb-nav.json

The shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).

npm dependencies

mapbaselucide-react

Registry dependencies

None.

Source

components/mapbase/seo-breadcrumb-nav.tsx
"use client"

import * as React from "react"
import {
  Mapbase,
  MapbaseError,
  type CountryCode,
  type SeoBreadcrumbResponse,
} from "mapbase"
import { ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"

export type SeoBreadcrumbNavProps = {
  apiKey: string
  baseUrl?: string
  lng: number
  lat: number
  country?: CountryCode
  /** Site origin for absolute links (e.g. https://example.com). */
  siteBaseUrl?: string
  separator?: string
  debounceMs?: number
  disabled?: boolean
  className?: string
}

/**
 * Accessible breadcrumb navigation backed by `GET /v1/seo/breadcrumb`.
 */
export function SeoBreadcrumbNav({
  apiKey,
  baseUrl,
  lng,
  lat,
  country,
  siteBaseUrl,
  debounceMs = 300,
  disabled,
  className,
}: SeoBreadcrumbNavProps) {
  const [data, setData] = React.useState<SeoBreadcrumbResponse | null>(null)
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState<MapbaseError | null>(null)

  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  const countryKey = country ?? ""

  React.useEffect(() => {
    if (disabled) return
    if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
      setData(null)
      setError(null)
      setLoading(false)
      return
    }

    const controller = new AbortController()
    const timer = window.setTimeout(async () => {
      setLoading(true)
      setError(null)

      const response = await client.seo.breadcrumb(
        {
          lng,
          lat,
          ...(country ? { country } : {}),
          ...(siteBaseUrl ? { base_url: siteBaseUrl } : {}),
        },
        { signal: controller.signal },
      )

      if (controller.signal.aborted) return

      if (response.error) {
        setError(response.error)
        setData(null)
      } else {
        setData(response.data)
      }
      setLoading(false)
    }, debounceMs)

    return () => {
      controller.abort()
      window.clearTimeout(timer)
    }
  }, [client, lng, lat, countryKey, siteBaseUrl, debounceMs, disabled])

  if (loading) {
    return (
      <p className={cn("text-xs text-muted-foreground", className)}>
        Loading breadcrumb…
      </p>
    )
  }

  if (error) {
    return (
      <p className={cn("text-xs text-destructive", className)} role="alert">
        {error.message}
      </p>
    )
  }

  if (!data || data.items.length === 0) {
    return (
      <p className={cn("text-xs text-muted-foreground", className)}>
        No breadcrumb for this point.
      </p>
    )
  }

  return (
    <nav aria-label="Breadcrumb" className={cn("text-sm", className)}>
      <ol className="flex flex-wrap items-center gap-1">
        {data.items.map((item, index) => {
          const isLast = index === data.items.length - 1
          return (
            <li key={item.id} className="inline-flex items-center gap-1">
              {index > 0 ? (
                <ChevronRight
                  className="size-3.5 shrink-0 text-muted-foreground"
                  aria-hidden
                />
              ) : null}
              {item.href && !isLast ? (
                <a
                  href={item.href}
                  className="text-muted-foreground hover:text-foreground"
                >
                  {item.name}
                </a>
              ) : (
                <span
                  className={isLast ? "font-medium" : "text-muted-foreground"}
                  aria-current={isLast ? "page" : undefined}
                >
                  {item.name}
                </span>
              )}
            </li>
          )
        })}
      </ol>
    </nav>
  )
}