{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contact-people",
  "title": "Contact People",
  "description": "A grid of person cards with role, languages, an email to copy and an optional booking link, filtered by team.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/contact-people/contact-people.tsx",
      "content": "// Contact people: a grid of person cards (initials or image avatar, name, role, language chips, email with a copy\n// button, optional \"Book a call\"). A team filter appears as Pills when there is more than one team.\n// Signature: copying an email shows \"Copied\" in place, announced through a live region.\n// Screen readers: a section named by its h2, a list of people with an h3 each; the filter is a radio group and the\n// result count is announced. Reduced motion: the press feedback on the buttons is dropped; nothing else moves.\nimport { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'\nimport { Check, Copy } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { Pills } from '@/registry/manniche/chart-kit/chart-kit'\n\nexport type ContactPerson = {\n  id: string\n  name: string\n  role: string\n  /** Team name; used for the filter. */\n  team?: string\n  /** Language chips: \"English\", \"Deutsch\". */\n  languages?: string[]\n  email: string\n  /** Optional booking link. */\n  bookHref?: string\n  /** Image slot; initials are shown when absent. */\n  avatar?: ReactNode\n}\n\nexport type ContactPeopleProps = Omit<HTMLAttributes<HTMLElement>, 'title'> & {\n  /** The section heading (h2). */\n  heading: ReactNode\n  intro?: ReactNode\n  people: ContactPerson[]\n  labels?: { all?: string; filter?: string; copy?: string; copied?: string; failed?: string; book?: string; languages?: string; shown?: (n: number) => string }\n}\n\nconst initials = (name: string) => name.split(/\\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join('')\n\nfunction CopyEmail({ email, L }: { email: string; L: { copy: string; copied: string; failed: string } }) {\n  const [s, setS] = useState<'idle' | 'copied' | 'failed'>('idle')\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n  useEffect(() => () => clearTimeout(timer.current), [])\n  const run = async () => {\n    try {\n      await navigator.clipboard.writeText(email)\n      setS('copied')\n    } catch {\n      setS('failed')\n    }\n    clearTimeout(timer.current)\n    timer.current = setTimeout(() => setS('idle'), 1800)\n  }\n  return (\n    <>\n      <button type=\"button\" onClick={run}\n        className=\"inline-flex min-h-11 items-center gap-1.5 rounded-full bg-muted px-3.5 text-sm font-medium outline-offset-2 transition-[opacity,transform] duration-200 ease-out-quint focus-visible:outline-2 focus-visible:outline-ring active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100\">\n        {s === 'copied' ? <Check className=\"size-4\" aria-hidden /> : <Copy className=\"size-4\" aria-hidden />}\n        <span aria-hidden className=\"font-mono text-xs\">{s === 'copied' ? L.copied : s === 'failed' ? L.failed : L.copy}</span>\n        <span className=\"sr-only\">{L.copy}: {email}</span>\n      </button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{s === 'copied' ? L.copied : s === 'failed' ? L.failed : ''}</span>\n    </>\n  )\n}\n\nexport function ContactPeople({ heading, intro, people, labels = {}, className, ...rest }: ContactPeopleProps) {\n  const L = { all: 'Everyone', filter: 'Team', copy: 'Copy email', copied: 'Copied', failed: 'Not copied', book: 'Book a call', languages: 'Languages', shown: (n: number) => `${n} people shown`, ...labels }\n  const uid = useId()\n  const teams = Array.from(new Set(people.map((p) => p.team).filter((t): t is string => !!t)))\n  const [team, setTeam] = useState('all')\n  // A team that is no longer in `people` falls back to everyone, so the list never ends up empty with no way out.\n  const active = team === 'all' || teams.includes(team) ? team : 'all'\n  const list = active === 'all' ? people : people.filter((p) => p.team === active)\n  return (\n    <section aria-labelledby={`${uid}-h`} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>\n      <header className=\"flex flex-wrap items-end justify-between gap-4\">\n        <div className=\"max-w-2xl\">\n          <h2 id={`${uid}-h`} className=\"font-[family-name:var(--font-display,inherit)] text-3xl font-semibold tracking-tight text-balance @min-[40rem]:text-4xl\">{heading}</h2>\n          {intro && <p className=\"mt-3 text-muted-foreground\">{intro}</p>}\n        </div>\n        {teams.length > 1 && (\n          <Pills label={L.filter} value={active} onChange={setTeam} options={[{ id: 'all', label: L.all }, ...teams.map((t) => ({ id: t, label: t }))]} />\n        )}\n      </header>\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">{L.shown(list.length)}</p>\n      <ul className=\"mt-8 grid list-none gap-3 p-0 @min-[34rem]:grid-cols-2 @min-[62rem]:grid-cols-3\">\n        {list.map((p) => (\n          <li key={p.id} className=\"min-w-0\">\n            <article className=\"flex h-full flex-col rounded-[26px] 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              <div className=\"flex items-center gap-3\">\n                <span className=\"grid size-12 shrink-0 place-items-center overflow-hidden rounded-full bg-muted font-mono text-sm\" aria-hidden={p.avatar ? undefined : true}>\n                  {p.avatar ?? initials(p.name)}\n                </span>\n                <div className=\"min-w-0\">\n                  <h3 className=\"text-lg font-semibold break-words text-pretty\">{p.name}</h3>\n                  <p className=\"text-sm text-muted-foreground\">{p.role}</p>\n                </div>\n              </div>\n              {p.languages && p.languages.length > 0 && (\n                <ul aria-label={L.languages} className=\"mt-4 flex list-none flex-wrap gap-1.5 p-0\">\n                  {p.languages.map((l) => (\n                    <li key={l} className=\"rounded-full bg-muted px-2.5 py-1 text-xs\">{l}</li>\n                  ))}\n                </ul>\n              )}\n              <p className=\"mt-4 font-mono text-xs break-all text-muted-foreground\">{p.email}</p>\n              <div className=\"mt-auto flex flex-wrap gap-2 pt-4\">\n                <CopyEmail email={p.email} L={L} />\n                {p.bookHref && (\n                  <a href={p.bookHref} className=\"inline-flex min-h-11 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\">\n                    {L.book}<span className=\"sr-only\">: {p.name}</span>\n                  </a>\n                )}\n              </div>\n            </article>\n          </li>\n        ))}\n      </ul>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}