{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footer-sitemap",
  "title": "Footer Sitemap",
  "description": "A dense sitemap footer with five to seven link groups that fold into disclosures on a narrow box, and a region picker.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/data-tile"
  ],
  "files": [
    {
      "path": "registry/manniche/footer-sitemap/footer-sitemap.tsx",
      "content": "// A dense sitemap footer for big sites: 5-7 link groups, a brand block and a region or language picker.\n// Signature: on a wide box every group is open and carries a mono count of its links; on a narrow box each group\n// folds into a disclosure. The switch follows the width of the footer's own box (a ResizeObserver, not the\n// viewport), and before it has been measured (server render) the groups are open, so nothing is hidden from a\n// reader without scripts.\n// Screen readers: a <footer> with a hidden h2; each group is an h3 (a button with aria-expanded when folded, plain\n// text when open) over a real list; the picker is a nav with aria-current on the chosen region.\n// Reduced motion: a group fades and lifts in when opened; under reduced motion it just appears. Height jumps by design.\nimport { ChevronDown } from 'lucide-react'\nimport { MotionConfig, motion } from 'motion/react'\nimport { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { DataTile } from '@/registry/manniche/data-tile/data-tile'\n\nexport type SitemapLink = {\n  label: string\n  href: string\n  /** Opens in a new tab with `rel=\"noopener noreferrer\"` and a hidden \"(opens in a new tab)\". */\n  external?: boolean\n}\n\nexport type SitemapGroup = {\n  /** The group heading: \"Product\". */\n  title: string\n  links: SitemapLink[]\n}\n\nexport type SitemapRegion = {\n  /** What the link says: \"Deutsch\", \"Denmark\". */\n  label: string\n  href: string\n  /** BCP 47 tag for `hreflang` and `lang` on the link: \"de\". */\n  lang?: string\n  /** The one the visitor is on. */\n  current?: boolean\n}\n\nexport type FooterSitemapProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {\n  /** The wordmark or logo slot. */\n  brand: ReactNode\n  /** One sentence about the site, under the brand. */\n  description?: ReactNode\n  /** The link groups, in order. Five to seven read best. */\n  groups: SitemapGroup[]\n  /** Regions or languages, as links. Left out, the picker is left out. */\n  regions?: SitemapRegion[]\n  /** The legal line: ©, terms, privacy. */\n  legal?: ReactNode\n  /** Width of the footer's box, in px, below which the groups fold. Default 640. */\n  collapseBelow?: number\n  /** Groups that start open when folded. Default none. */\n  defaultOpen?: string[]\n  /** Interface strings, for translation. */\n  labels?: {\n    /** Hidden heading of the footer. */\n    heading?: string\n    /** Name of the region picker. */\n    regions?: string\n    /** Hidden text after an external link. */\n    newTab?: string\n  }\n}\n\nconst L = { heading: 'Sitemap', regions: 'Region and language', newTab: '(opens in a new tab)' }\n\nconst focus = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'\n\n/** True when the element's own box is at least `min` px wide. Null until measured. */\nfunction useWide(min: number) {\n  const ref = useRef<HTMLDivElement>(null)\n  const [wide, setWide] = useState<boolean | null>(null)\n  useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const ro = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= min))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [min])\n  return [ref, wide] as const\n}\n\nfunction LinkList({ links, newTab }: { links: SitemapLink[]; newTab: string }) {\n  return (\n    <ul className=\"grid\">\n      {links.map((l) => (\n        <li key={l.href + l.label}>\n          <a\n            href={l.href}\n            {...(l.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}\n            className={cn(\n              // The row is the 44 px target, so neighbouring targets never overlap.\n              'inline-flex h-11 items-center rounded-sm text-[14.5px] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',\n              focus,\n            )}\n          >\n            {l.label}\n            {l.external && <span className=\"sr-only\"> {newTab}</span>}\n          </a>\n        </li>\n      ))}\n    </ul>\n  )\n}\n\nfunction Group({ group, folded, open, onToggle, newTab }: { group: SitemapGroup; folded: boolean; open: boolean; onToggle: () => void; newTab: string }) {\n  const id = useId()\n  const count = (\n    <span className=\"font-mono text-[11px] text-muted-foreground tabular-nums\" aria-hidden>\n      {String(group.links.length).padStart(2, '0')}\n    </span>\n  )\n  if (!folded) {\n    return (\n      <div className=\"min-w-0\">\n        <h3 className=\"flex min-h-11 items-center gap-2 text-[15px] font-medium tracking-[-0.01em]\">\n          {group.title}\n          {count}\n        </h3>\n        <LinkList links={group.links} newTab={newTab} />\n      </div>\n    )\n  }\n  return (\n    <div className=\"min-w-0 border-t first:border-t-0\">\n      <h3>\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={id}\n          onClick={onToggle}\n          className={cn('flex min-h-14 w-full cursor-pointer items-center gap-3 rounded-sm text-left text-[15px] font-medium tracking-[-0.01em]', focus)}\n        >\n          {group.title}\n          {count}\n          <ChevronDown\n            aria-hidden\n            className={cn('ml-auto size-4 flex-none text-muted-foreground transition-[transform] duration-200 ease-out-quint', open && 'rotate-180')}\n          />\n        </button>\n      </h3>\n      <div id={id} role=\"region\" aria-label={group.title} hidden={!open}>\n        {open && (\n          <motion.div initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }} className=\"pb-3\">\n            <LinkList links={group.links} newTab={newTab} />\n          </motion.div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport function FooterSitemap({ brand, description, groups, regions, legal, collapseBelow = 640, defaultOpen = [], labels, className, ...rest }: FooterSitemapProps) {\n  const t = { ...L, ...labels }\n  const [ref, wide] = useWide(collapseBelow)\n  const folded = wide === false\n  const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen))\n  const toggle = (title: string) =>\n    setOpen((prev) => {\n      const next = new Set(prev)\n      if (!next.delete(title)) next.add(title)\n      return next\n    })\n\n  return (\n    <footer className={cn('mx-auto w-full max-w-6xl px-4 sm:px-6', className)} {...rest}>\n      <h2 className=\"sr-only\">{t.heading}</h2>\n      <MotionConfig reducedMotion=\"user\">\n        <div ref={ref} className=\"@container\">\n          <DataTile>\n            <div className=\"grid gap-8 py-1 @min-[64rem]:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] @min-[64rem]:gap-12\">\n              <div className=\"grid content-start gap-3\">\n                <div className=\"text-lg font-semibold tracking-tight\">{brand}</div>\n                {description && <p className=\"max-w-[34ch] text-sm leading-relaxed text-pretty text-muted-foreground\">{description}</p>}\n              </div>\n              <div\n                className={cn(\n                  'grid min-w-0',\n                  folded ? 'grid-cols-1' : 'grid-cols-[repeat(auto-fill,minmax(10.5rem,1fr))] gap-x-6 gap-y-6',\n                )}\n              >\n                {groups.map((g) => (\n                  <Group key={g.title} group={g} folded={folded} open={open.has(g.title)} onToggle={() => toggle(g.title)} newTab={t.newTab} />\n                ))}\n              </div>\n            </div>\n\n            {(regions?.length || legal) && (\n              <div className=\"mt-8 flex flex-col gap-4 border-t pt-5 @min-[48rem]:flex-row @min-[48rem]:items-center @min-[48rem]:justify-between\">\n                {regions && regions.length > 0 && (\n                  <nav aria-label={t.regions}>\n                    {/* 32 px pills + 12 px row gap: the 44 px hit areas of wrapped rows meet without overlapping. */}\n                    <ul className=\"flex flex-wrap gap-x-1.5 gap-y-3\">\n                      {regions.map((r) => (\n                        <li key={r.href + r.label}>\n                          <a\n                            href={r.href}\n                            hrefLang={r.lang}\n                            lang={r.lang}\n                            aria-current={r.current ? 'true' : undefined}\n                            className={cn(\n                              'relative inline-flex h-8 min-w-11 items-center justify-center rounded-full px-3 text-[13.5px] underline-offset-4',\n                              'before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[\"\"]',\n                              r.current ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground',\n                              focus,\n                            )}\n                          >\n                            {r.label}\n                          </a>\n                        </li>\n                      ))}\n                    </ul>\n                  </nav>\n                )}\n                {legal && <div className=\"flex flex-wrap items-center gap-x-5 gap-y-1 text-[13px] text-muted-foreground\">{legal}</div>}\n              </div>\n            )}\n          </DataTile>\n        </div>\n      </MotionConfig>\n    </footer>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}