{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footer-columns",
  "title": "Footer Columns",
  "description": "A site footer with a brand block, three to five link columns, a newsletter tile and a legal row.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/footer-columns/footer-columns.tsx",
      "content": "// The classic site footer in the Tiles language: a brand block, three to five link columns, a newsletter tile and a\n// legal row. Signature: the newsletter sits in its own tile beside the columns; on narrow boxes the columns go two-up\n// and the newsletter tile comes first. It adapts to the box it sits in (container queries), not the viewport.\n// Screen readers get a <footer> landmark, one <nav> with a list per column (each under an h3), a labelled email form\n// whose errors are linked to the field, and a status region that announces the sent state. The ©-year is computed\n// after mount unless `now` is passed, so the first render is pure. Under reduced motion nothing animates.\nimport { ArrowRight, Check, Globe } from 'lucide-react'\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type FormEvent, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\n\nexport type FooterLink = {\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 FooterColumn = {\n  /** The column heading, e.g. \"Product\". */\n  title: string\n  links: FooterLink[]\n}\n\nexport type FooterNewsletter = {\n  /** The tile heading. */\n  title: string\n  /** One sentence on what people get and how often. */\n  description: string\n  /** Called with the trimmed email when the form is valid. Reject to show the error state. Nothing is sent otherwise. */\n  onSubmit?: (email: string) => Promise<void> | void\n  /** Fine print under the form, e.g. a privacy link. */\n  note?: ReactNode\n}\n\nexport type FooterRegion = {\n  /** Read aloud for the select, e.g. \"Language\". */\n  label: string\n  value: string\n  options: { value: string; label: string }[]\n  onChange: (value: string) => void\n}\n\nexport type FooterColumnsLabels = {\n  /** Hidden heading of the footer. */\n  heading?: string\n  navigation?: string\n  email?: string\n  subscribe?: string\n  subscribing?: string\n  invalidEmail?: string\n  emptyEmail?: string\n  failed?: string\n  sentTitle?: string\n  sentBody?: string\n  useAnother?: string\n  newTab?: string\n}\n\nexport type FooterColumnsProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {\n  /** The wordmark or logo. Rendered as given; wrap it in a link yourself if it should go home. */\n  brand: ReactNode\n  /** One line on what the company does. */\n  description?: ReactNode\n  /** Three to five link columns. */\n  columns: FooterColumn[]\n  /** The newsletter tile. Leave out to drop the tile and let the columns take the width. */\n  newsletter?: FooterNewsletter\n  /** The name after the © sign, e.g. \"Halden Studio\". */\n  owner: string\n  /** Legal links: privacy, terms, imprint. */\n  legalLinks?: FooterLink[]\n  /** A language or region select at the end of the legal row. */\n  region?: FooterRegion\n  /** Pins the © year and keeps the first render pure. When absent the year appears after mount. */\n  now?: Date\n  /** UI strings, for translation. */\n  labels?: FooterColumnsLabels\n}\n\nconst DEFAULT_LABELS: Required<FooterColumnsLabels> = {\n  heading: 'Site footer',\n  navigation: 'Footer',\n  email: 'Email address',\n  subscribe: 'Subscribe',\n  subscribing: 'Subscribing',\n  invalidEmail: 'That email does not look right. Check it for a typo, like name@example.com.',\n  emptyEmail: 'Enter your email address, like name@example.com.',\n  failed: 'We could not sign you up just now. Check your connection and try again.',\n  sentTitle: 'You are on the list.',\n  sentBody: 'We will write to',\n  useAnother: 'Use another address',\n  newTab: '(opens in a new tab)',\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/\n\n// The page's ring colour, drawn inside the link's own box so a tile never clips it.\nconst LINK =\n  'relative inline-flex min-h-11 items-center rounded-md text-sm text-muted-foreground transition-[color] duration-200 ease-out-quint hover:text-foreground focus-visible:text-foreground focus-visible:outline-offset-0'\n\nfunction FooterAnchor({ link, newTab, className }: { link: FooterLink; newTab: string; className?: string }) {\n  return (\n    <a\n      href={link.href}\n      className={cn(LINK, className)}\n      {...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}\n    >\n      {link.label}\n      {link.external && <span className=\"sr-only\"> {newTab}</span>}\n    </a>\n  )\n}\n\nconst noopSubscribe = () => () => {}\n// null on the server and in the first client render, the real year once mounted.\nfunction useYear(now?: Date) {\n  const clientYear = useSyncExternalStore(noopSubscribe, () => new Date().getFullYear(), () => null)\n  return now ? now.getFullYear() : clientYear\n}\n\ntype Status = 'idle' | 'sending' | 'sent' | 'error'\n\nfunction NewsletterTile({ data, labels }: { data: FooterNewsletter; labels: Required<FooterColumnsLabels> }) {\n  const uid = useId()\n  const inputRef = useRef<HTMLInputElement>(null)\n  const sentRef = useRef<HTMLDivElement>(null)\n  const [email, setEmail] = useState('')\n  const [error, setError] = useState<string | null>(null)\n  const [status, setStatus] = useState<Status>('idle')\n  const [sentTo, setSentTo] = useState('')\n  const mounted = useRef(true)\n  useEffect(() => {\n    mounted.current = true\n    return () => {\n      mounted.current = false\n    }\n  }, [])\n  useEffect(() => {\n    if (status === 'sent') sentRef.current?.focus()\n  }, [status])\n\n  const check = (value: string) => {\n    const v = value.trim()\n    if (!v) return labels.emptyEmail\n    return EMAIL.test(v) ? null : labels.invalidEmail\n  }\n\n  const submit = async (e: FormEvent) => {\n    e.preventDefault()\n    if (status === 'sending') return\n    const problem = check(email)\n    setError(problem)\n    if (problem) {\n      inputRef.current?.focus()\n      return\n    }\n    const value = email.trim()\n    setStatus('sending')\n    try {\n      await data.onSubmit?.(value)\n      if (!mounted.current) return\n      setSentTo(value)\n      setStatus('sent')\n    } catch {\n      if (!mounted.current) return\n      setStatus('error')\n    }\n  }\n\n  const sent = status === 'sent'\n  const errorId = `${uid}-error`\n  const shown = error ?? (status === 'error' ? labels.failed : null)\n\n  return (\n    <section\n      aria-labelledby={`${uid}-title`}\n      className=\"@container/news flex min-w-0 flex-col rounded-[calc(var(--radius)*2+2px)] bg-card p-6 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]\"\n    >\n      <h3\n        id={`${uid}-title`}\n        className=\"text-xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance\"\n        style={{ fontFamily: 'var(--font-display, inherit)' }}\n      >\n        {data.title}\n      </h3>\n      <p className=\"mt-2 text-sm leading-normal text-pretty text-muted-foreground\">{data.description}</p>\n\n      {/* Always mounted, so the sent state is announced when it arrives. */}\n      <div role=\"status\" ref={sentRef} tabIndex={-1} className=\"outline-none\">\n        {sent && (\n          <div className=\"mt-5 flex items-start gap-3 rounded-[14px] bg-muted p-4\">\n            <span className=\"mt-0.5 grid size-6 flex-none place-items-center rounded-full bg-foreground text-background\">\n              <Check className=\"size-3.5\" aria-hidden strokeWidth={2.5} />\n            </span>\n            <div className=\"min-w-0 text-sm leading-normal\">\n              <p className=\"font-medium\">{labels.sentTitle}</p>\n              <p className=\"text-muted-foreground\">\n                {labels.sentBody} <span className=\"font-mono text-[13px] break-all text-foreground tabular-nums\">{sentTo}</span>.\n              </p>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {sent ? (\n        <button\n          type=\"button\"\n          onClick={() => {\n            setEmail('')\n            setError(null)\n            setStatus('idle')\n            requestAnimationFrame(() => inputRef.current?.focus())\n          }}\n          className=\"mt-2 inline-flex min-h-11 w-fit items-center rounded-md text-sm text-muted-foreground underline underline-offset-4 transition-[color] duration-200 ease-out-quint hover:text-foreground\"\n        >\n          {labels.useAnother}\n        </button>\n      ) : (\n        <form noValidate onSubmit={submit} className=\"mt-5 grid gap-2\">\n          <label htmlFor={`${uid}-email`} className=\"text-[13px] font-medium\">\n            {labels.email}\n          </label>\n          {/* Side by side only when the tile itself has room; it is a narrow column on wide pages. */}\n          <div className=\"flex flex-col gap-2 @min-[22rem]/news:flex-row\">\n            <input\n              ref={inputRef}\n              id={`${uid}-email`}\n              name=\"email\"\n              type=\"email\"\n              inputMode=\"email\"\n              autoComplete=\"email\"\n              spellCheck={false}\n              value={email}\n              aria-invalid={shown ? true : undefined}\n              aria-describedby={shown ? errorId : undefined}\n              onChange={(e) => {\n                setEmail(e.target.value)\n                if (error) setError(null)\n                if (status === 'error') setStatus('idle')\n              }}\n              onBlur={() => {\n                if (email.trim()) setError(check(email))\n              }}\n              className={cn(\n                // flex-1 only in the row: in a column its zero basis would squash the field below 44 px.\n                'h-11 w-full min-w-0 rounded-[14px] @min-[22rem]/news:flex-1 border bg-background px-3.5 text-base text-foreground placeholder:text-muted-foreground',\n                'transition-[opacity] duration-200',\n                shown ? 'border-destructive' : 'border-foreground/25',\n              )}\n            />\n            <button\n              type=\"submit\"\n              aria-disabled={status === 'sending' || undefined}\n              className={cn(\n                'inline-flex h-11 flex-none cursor-pointer items-center justify-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground',\n                'transition-[opacity,transform] duration-200 ease-out-quint active:scale-[0.97] motion-reduce:transition-none',\n                status === 'sending' && 'cursor-progress opacity-70',\n              )}\n            >\n              {status === 'sending' ? labels.subscribing : labels.subscribe}\n              {status !== 'sending' && <ArrowRight className=\"size-4\" aria-hidden />}\n            </button>\n          </div>\n          <p\n            id={errorId}\n            className={cn('text-[13px] leading-snug text-destructive', !shown && 'hidden')}\n            role={status === 'error' ? 'alert' : undefined}\n          >\n            {shown}\n          </p>\n        </form>\n      )}\n      {data.note && <div className=\"mt-3 text-xs leading-normal text-muted-foreground\">{data.note}</div>}\n    </section>\n  )\n}\n\n/** A site footer with link columns, a newsletter tile and a legal row. */\nexport function FooterColumns({\n  brand,\n  description,\n  columns,\n  newsletter,\n  owner,\n  legalLinks = [],\n  region,\n  now,\n  labels,\n  className,\n  ...rest\n}: FooterColumnsProps) {\n  const l = { ...DEFAULT_LABELS, ...labels }\n  const year = useYear(now)\n  const regionId = useId()\n\n  return (\n    <footer {...rest} className={cn('@container w-full bg-background text-foreground', className)}>\n      <h2 className=\"sr-only\">{l.heading}</h2>\n      <div className=\"mx-auto w-full max-w-6xl px-4 py-12 @min-[40rem]:py-16 sm:px-6\">\n        <div\n          className={cn(\n            'grid gap-x-12 gap-y-10',\n            newsletter && '@min-[56rem]:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)] @min-[56rem]:items-start',\n          )}\n        >\n          {newsletter && (\n            <div className=\"order-first min-w-0 @min-[56rem]:order-last\">\n              <NewsletterTile data={newsletter} labels={l} />\n            </div>\n          )}\n          <div className=\"grid min-w-0 gap-10\">\n            <div className=\"max-w-sm\">\n              <div className=\"text-lg font-semibold tracking-[-0.02em]\" style={{ fontFamily: 'var(--font-display, inherit)' }}>\n                {brand}\n              </div>\n              {description && <p className=\"mt-3 text-sm leading-normal text-pretty text-muted-foreground\">{description}</p>}\n            </div>\n            <nav aria-label={l.navigation}>\n              <div className=\"grid grid-cols-2 gap-x-6 gap-y-8 @min-[40rem]:grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))]\">\n                {columns.map((col) => (\n                  <div key={col.title} className=\"min-w-0\">\n                    <h3 className=\"font-mono text-[11px] font-medium tracking-[0.06em] text-muted-foreground uppercase\">{col.title}</h3>\n                    <ul className=\"mt-2\">\n                      {col.links.map((link) => (\n                        <li key={link.href + link.label}>\n                          <FooterAnchor link={link} newTab={l.newTab} />\n                        </li>\n                      ))}\n                    </ul>\n                  </div>\n                ))}\n              </div>\n            </nav>\n          </div>\n        </div>\n\n        <div className=\"mt-12 flex flex-col gap-x-8 gap-y-2 border-t border-border pt-4 @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between\">\n          <div className=\"flex min-w-0 flex-col gap-x-6 @min-[40rem]:flex-row @min-[40rem]:items-center\">\n            <p className=\"min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums\">\n              &copy; {year ?? ''}\n              {year ? ' ' : ''}\n              {owner}\n            </p>\n            {legalLinks.length > 0 && (\n              <ul className=\"flex flex-wrap gap-x-5\">\n                {legalLinks.map((link) => (\n                  <li key={link.href + link.label}>\n                    <FooterAnchor link={link} newTab={l.newTab} className=\"text-[13px]\" />\n                  </li>\n                ))}\n              </ul>\n            )}\n          </div>\n          {region && (\n            <div className=\"relative flex w-fit items-center\">\n              <label htmlFor={regionId} className=\"sr-only\">\n                {region.label}\n              </label>\n              <Globe className=\"pointer-events-none absolute left-3 size-4 text-muted-foreground\" aria-hidden />\n              <select\n                id={regionId}\n                value={region.value}\n                onChange={(e) => region.onChange(e.target.value)}\n                className=\"h-11 cursor-pointer rounded-full border border-foreground/25 bg-background pr-4 pl-9 text-[13px] text-foreground\"\n              >\n                {region.options.map((o) => (\n                  <option key={o.value} value={o.value}>\n                    {o.label}\n                  </option>\n                ))}\n              </select>\n            </div>\n          )}\n        </div>\n      </div>\n    </footer>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}