{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "directory-1",
  "title": "Directory 1: people directory",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A people directory: search across names, roles, teams and cities, department chips with live counts, a location filter, a grid and a compact list, each person with availability and their local time right now.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/directory-1/directory-1.tsx",
      "content": "// Ballmac UI: Directory 1. https://ui.ballmac.com/blocks/directory-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Clock, LayoutGrid, List, Mail, MapPin, Search, UsersRound, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Directory1Person = {\n  id: string\n  name: string\n  title: string\n  department: string\n  location: string\n  /** Hours from UTC, such as 1 or -5 or 5.5. Used to show their local time. */\n  utcOffset: number\n  email?: string\n  /** \"she/her\", shown beside the name when given. */\n  pronouns?: string\n  status?: \"available\" | \"away\" | \"out\"\n}\n\ntype Directory1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    people?: Directory1Person[]\n    title?: string\n    description?: string\n    /** An ISO date and time in UTC for \"now\". The block never reads the clock. */\n    now?: string\n    locale?: string\n    defaultView?: \"grid\" | \"list\"\n    onSelect?: (person: Directory1Person) => void\n  }\n\nconst sample: Directory1Person[] = [\n  { id: \"1\", name: \"Mara Ellison\", title: \"Chief Executive\", department: \"Leadership\", location: \"Lisbon\", utcOffset: 1, email: \"mara@acme.com\", pronouns: \"she/her\", status: \"available\" },\n  { id: \"2\", name: \"Theo Nakamura\", title: \"Chief Technology Officer\", department: \"Engineering\", location: \"Osaka\", utcOffset: 9, email: \"theo@acme.com\", status: \"away\" },\n  { id: \"3\", name: \"Priya Shah\", title: \"Head of Platform\", department: \"Engineering\", location: \"Lisbon\", utcOffset: 1, email: \"priya@acme.com\", pronouns: \"she/her\", status: \"available\" },\n  { id: \"4\", name: \"Diego Marín\", title: \"Staff Engineer\", department: \"Engineering\", location: \"Madrid\", utcOffset: 2, email: \"diego@acme.com\", status: \"available\" },\n  { id: \"5\", name: \"Kofi Mensah\", title: \"Senior Engineer\", department: \"Engineering\", location: \"Accra\", utcOffset: 0, email: \"kofi@acme.com\", status: \"out\" },\n  { id: \"6\", name: \"Noor Haddad\", title: \"Head of Design\", department: \"Design\", location: \"Porto\", utcOffset: 1, email: \"noor@acme.com\", pronouns: \"she/they\", status: \"available\" },\n  { id: \"7\", name: \"Amara Okoye\", title: \"Product Designer\", department: \"Design\", location: \"Lagos\", utcOffset: 1, email: \"amara@acme.com\", status: \"available\" },\n  { id: \"8\", name: \"Jordan Lee\", title: \"Design Systems Lead\", department: \"Design\", location: \"New York\", utcOffset: -4, email: \"jordan@acme.com\", status: \"away\" },\n  { id: \"9\", name: \"Sam Okafor\", title: \"Head of Support\", department: \"Support\", location: \"Dublin\", utcOffset: 1, email: \"sam@acme.com\", status: \"available\" },\n  { id: \"10\", name: \"Mia Laurent\", title: \"Support Specialist\", department: \"Support\", location: \"Paris\", utcOffset: 2, email: \"mia@acme.com\", status: \"available\" },\n  { id: \"11\", name: \"Alex Kim\", title: \"Support Lead\", department: \"Support\", location: \"Seoul\", utcOffset: 9, email: \"alex@acme.com\", status: \"out\" },\n  { id: \"12\", name: \"Ana Souza\", title: \"Head of Operations\", department: \"Operations\", location: \"Lisbon\", utcOffset: 1, email: \"ana@acme.com\", status: \"available\" },\n  { id: \"13\", name: \"Oliver Reed\", title: \"Finance Manager\", department: \"Operations\", location: \"London\", utcOffset: 1, email: \"oliver@acme.com\", pronouns: \"he/him\", status: \"away\" },\n  { id: \"14\", name: \"Yuki Tanaka\", title: \"Frontend Engineer\", department: \"Engineering\", location: \"Tokyo\", utcOffset: 9, email: \"yuki@acme.com\", status: \"available\" },\n]\n\nconst statusMeta = { available: { label: \"Available\", dot: \"bg-chart-2\" }, away: { label: \"Away\", dot: \"bg-amber-400\" }, out: { label: \"Out of office\", dot: \"bg-muted-foreground/50\" } }\nconst initials = (n: string) => n.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\n\nfunction Avatar({ p, size }: { p: Directory1Person; size: \"md\" | \"lg\" }) {\n  const s = p.status ? statusMeta[p.status] : null\n  return (\n    <span aria-hidden=\"true\" className={cn(\"relative flex shrink-0 items-center justify-center rounded-full border font-semibold\", size === \"lg\" ? \"size-16 text-lg\" : \"size-11 text-sm\")}>\n      <span className=\"absolute inset-0 overflow-hidden rounded-full\">\n        <CoverArt seed={p.name} variant=\"mesh\" className=\"absolute inset-0\" />\n      </span>\n      <span className=\"relative\">{initials(p.name)}</span>\n      {s ? <span className={cn(\"border-card absolute end-0 bottom-0 size-3.5 rounded-full border-2\", s.dot)} /> : null}\n    </span>\n  )\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n      {Array.from({ length: 6 }, (_, i) => (\n        <div key={i} className=\"bg-card rounded-2xl border p-5\">\n          <div className=\"flex items-center gap-4\">\n            <Skeleton className=\"size-16 rounded-full\" />\n            <div className=\"grid flex-1 gap-2\">\n              <Skeleton className=\"h-4 w-2/3\" />\n              <Skeleton className=\"h-3 w-1/2\" />\n            </div>\n          </div>\n          <Skeleton className=\"mt-5 h-3 w-1/3\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * A people directory: search across names, roles and departments, department chips with counts, a location filter, a grid and a compact list,\n * each person with availability and their local time right now. The time is worked out from the moment you pass, never from the clock.\n */\nfunction Directory1({\n  people = sample,\n  title = \"Our people\",\n  description = \"Find who does what, where they are, and whether it is a good time to write.\",\n  now = \"2026-10-18T10:42:00Z\",\n  locale = \"en\",\n  defaultView = \"grid\",\n  onSelect,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Directory1Props) {\n  const id = React.useId()\n  const all = state === \"empty\" ? [] : people\n  const [query, setQuery] = React.useState(\"\")\n  const [dept, setDept] = React.useState<string | null>(null)\n  const [place, setPlace] = React.useState(\"all\")\n  const [view, setView] = React.useState<\"grid\" | \"list\">(defaultView)\n  const tokens = queryTokens(query)\n  const fmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"2-digit\", minute: \"2-digit\", timeZone: \"UTC\" }), [locale])\n  const nowMs = Date.parse(now)\n  const localTime = (p: Directory1Person) => fmt.format(nowMs + p.utcOffset * 3_600_000)\n  const localHour = (p: Directory1Person) => new Date(nowMs + p.utcOffset * 3_600_000).getUTCHours()\n\n  const matches = (p: Directory1Person) => !tokens.length || matchesAll(`${p.name} ${p.title} ${p.department} ${p.location}`, tokens)\n  const departments = [...new Set(all.map((p) => p.department))]\n  const places = [...new Set(all.map((p) => p.location))].sort()\n  const inPlace = (p: Directory1Person) => place === \"all\" || p.location === place\n  const shown = all.filter((p) => matches(p) && inPlace(p) && (!dept || p.department === dept)).sort((a, b) => a.name.localeCompare(b.name, locale))\n  const countFor = (d: string | null) => all.filter((p) => matches(p) && inPlace(p) && (d ? p.department === d : true)).length\n  const filtered = !!(tokens.length || dept || place !== \"all\")\n\n  const card = (p: Directory1Person) => {\n    const night = localHour(p) < 7 || localHour(p) >= 21\n    const s = p.status ? statusMeta[p.status] : null\n    const body = (\n      <>\n        <Avatar p={p} size={view === \"grid\" ? \"lg\" : \"md\"} />\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate font-semibold tracking-tight\">\n            <MatchHighlight text={p.name} query={tokens} />\n            {p.pronouns ? <span className=\"text-muted-foreground ms-2 text-xs font-normal\">{p.pronouns}</span> : null}\n          </span>\n          <span className=\"text-muted-foreground block truncate text-sm\">\n            <MatchHighlight text={p.title} query={tokens} />\n          </span>\n          {view === \"grid\" ? (\n            <span className=\"bg-muted mt-2 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium\">\n              <MatchHighlight text={p.department} query={tokens} />\n            </span>\n          ) : null}\n        </span>\n      </>\n    )\n    return view === \"grid\" ? (\n      <li key={p.id}>\n        <article className=\"bg-card hover:border-foreground/30 group relative h-full rounded-2xl border p-5 transition-[border-color,box-shadow] duration-150 hover:shadow-md motion-reduce:transition-none\">\n          <button type=\"button\" onClick={() => onSelect?.(p)} className=\"focus-visible:ring-ring/50 flex w-full items-center gap-4 rounded-lg text-start outline-none after:absolute after:inset-0 after:rounded-2xl focus-visible:ring-[3px]\">\n            {body}\n          </button>\n          <dl className=\"text-muted-foreground mt-5 grid gap-2 border-t pt-4 text-sm\">\n            <div className=\"flex items-center gap-2\">\n              <dt className=\"sr-only\">Location</dt>\n              <MapPin aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n              <dd className=\"truncate\">\n                <MatchHighlight text={p.location} query={tokens} />\n              </dd>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <dt className=\"sr-only\">Their time</dt>\n              <Clock aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n              <dd className=\"tabular-nums\">\n                {localTime(p)}\n                {night ? <span> · probably offline</span> : null}\n              </dd>\n            </div>\n            {s ? (\n              <div className=\"flex items-center gap-2\">\n                <dt className=\"sr-only\">Status</dt>\n                <span aria-hidden=\"true\" className={cn(\"ms-1 size-2 rounded-full\", s.dot)} />\n                <dd>{s.label}</dd>\n              </div>\n            ) : null}\n          </dl>\n          {p.email ? (\n            <a href={`mailto:${p.email}`} aria-label={`Email ${p.name}`} className=\"hover:bg-accent focus-visible:ring-ring/50 absolute end-4 top-4 z-10 flex size-8 items-center justify-center rounded-full border opacity-0 outline-none transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:ring-[3px] motion-reduce:transition-none [@media(hover:none)]:opacity-100\">\n              <Mail aria-hidden=\"true\" className=\"size-4\" />\n            </a>\n          ) : null}\n        </article>\n      </li>\n    ) : (\n      <li key={p.id} className=\"hover:bg-accent/40 relative flex items-center gap-4 px-4 py-3 transition-colors motion-reduce:transition-none\">\n        <button type=\"button\" onClick={() => onSelect?.(p)} className=\"focus-visible:ring-ring/50 flex min-w-0 flex-1 items-center gap-4 rounded-lg text-start outline-none after:absolute after:inset-0 focus-visible:ring-[3px]\">\n          {body}\n        </button>\n        <span className=\"text-muted-foreground hidden w-32 truncate text-sm md:block\">\n          <span className=\"sr-only\">Department: </span>\n          <MatchHighlight text={p.department} query={tokens} />\n        </span>\n        <span className=\"text-muted-foreground hidden w-28 truncate text-sm sm:block\">\n          <span className=\"sr-only\">Location: </span>\n          <MatchHighlight text={p.location} query={tokens} />\n        </span>\n        <span className=\"text-muted-foreground w-14 text-end text-sm tabular-nums\">\n          <span className=\"sr-only\">Their time: </span>\n          {localTime(p)}\n        </span>\n      </li>\n    )\n  }\n\n  const content = !all.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <UsersRound className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"Nobody is listed yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"People appear here once they are added to the directory.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <>\n      <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center\">\n        <div className=\"bg-background focus-within:ring-ring/50 flex h-10 min-w-0 flex-1 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px]\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n          <input type=\"search\" aria-label=\"Search people\" placeholder=\"Search by name, role, team or city\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n        </div>\n        <div className=\"flex items-center gap-3\">\n          <Select value={place} onValueChange={setPlace}>\n            <SelectTrigger aria-label=\"Location\" className=\"w-44\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"all\">All locations</SelectItem>\n              {places.map((l) => (\n                <SelectItem key={l} value={l}>\n                  {l}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n          <SegmentedControl value={view} onValueChange={(v) => setView(v as \"grid\" | \"list\")} aria-label=\"View\" size=\"sm\">\n            <SegmentedControlItem value=\"grid\" aria-label=\"Grid\">\n              <LayoutGrid aria-hidden=\"true\" className=\"size-4\" />\n            </SegmentedControlItem>\n            <SegmentedControlItem value=\"list\" aria-label=\"List\">\n              <List aria-hidden=\"true\" className=\"size-4\" />\n            </SegmentedControlItem>\n          </SegmentedControl>\n        </div>\n      </div>\n      <div role=\"group\" aria-label=\"Filter by department\" className=\"-mx-4 mt-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0\">\n        {[null, ...departments].map((d) => (\n          <button\n            key={d ?? \"all\"}\n            type=\"button\"\n            aria-pressed={dept === d}\n            onClick={() => setDept(d)}\n            className={cn(\"focus-visible:ring-ring/50 shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", dept === d ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}\n          >\n            {d ?? \"Everyone\"} <span className={cn(\"tabular-nums\", dept === d ? \"opacity-70\" : \"text-muted-foreground\")}>{countFor(d)}</span>\n          </button>\n        ))}\n      </div>\n\n      <p aria-live=\"polite\" className=\"text-muted-foreground mt-6 text-sm\">\n        {shown.length} {shown.length === 1 ? \"person\" : \"people\"}\n        {filtered ? \" match\" : \"\"}\n      </p>\n\n      {shown.length ? (\n        view === \"grid\" ? (\n          <ul className=\"mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">{shown.map(card)}</ul>\n        ) : (\n          <ul className=\"bg-card mt-4 divide-y overflow-hidden rounded-2xl border\">{shown.map(card)}</ul>\n        )\n      ) : (\n        <div className=\"mt-4 rounded-2xl border border-dashed px-6 py-14 text-center\">\n          <p className=\"font-medium\">Nobody matches</p>\n          <p className=\"text-muted-foreground mt-1 text-sm\">Try fewer words, or look across every team and city.</p>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"mt-4\"\n            onClick={() => {\n              setQuery(\"\")\n              setDept(null)\n              setPlace(\"all\")\n            }}\n          >\n            <X aria-hidden=\"true\" />\n            Clear filters\n          </Button>\n        </div>\n      )}\n    </>\n  )\n\n  return (\n    <section data-slot=\"directory-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 sm:py-16\", className)} {...frameProps(props)}>\n      <header className=\"mb-8 max-w-2xl\">\n        <h1 id={`${id}-title`} className=\"text-4xl font-semibold tracking-[-0.04em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-3 text-lg text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the directory\" loading={<Skel />} error={{ title: errorState?.title ?? \"The directory could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Directory1, type Directory1Props, type Directory1Person }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/directory-1/directory-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "directory",
      "people",
      "team",
      "employees",
      "contacts",
      "company",
      "who is who",
      "time zones",
      "staff"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A people directory. <Directory1 people={[{ id, name, title, department, location, utcOffset: 1, email, pronouns, status: 'available' | 'away' | 'out' }]} now='2026-10-18T10:42:00Z' locale defaultView='grid' | 'list' onSelect />. Local times are worked out from now plus each utcOffset; pass now yourself, the block never reads the clock. Department chips and the location menu come from the data.",
      "whenToUse": [
        "An internal company directory or a team page with filters",
        "Remote teams that need to see where people are and what time it is for them"
      ],
      "whenNotToUse": [
        "A reporting hierarchy (use orgchart-1)",
        "A marketing team section with photos (use team-1)"
      ],
      "composesWith": [
        "orgchart-1",
        "app-shell-1",
        "about-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Search, location, view, department buttons (pressed state announced), then one button per person; the email shortcut is its own link"
        },
        {
          "keys": "Count",
          "action": "'7 people match' is announced politely as filters change"
        },
        {
          "keys": "Availability",
          "action": "Written as a word (Available, Away, Out of office) beside the dot, never by color alone"
        }
      ],
      "customization": [
        "Local time uses each person's utcOffset, so half-hour zones work",
        "A person between 21:00 and 07:00 locally is marked probably offline",
        "Pictures are drawn from the name, so identical on server and client"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "directory-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Nobody listed",
        "example": "directory-1-empty",
        "trigger": "state=\"empty\" (or people={[]})",
        "note": "Says people appear once added; emptyState sets the copy. A search with no match says so with Clear filters."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "directory-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "directory-1-demo",
      "directory-1-list",
      "directory-1-loading",
      "directory-1-empty",
      "directory-1-error"
    ],
    "url": "https://ui.ballmac.com/components/directory-1"
  },
  "categories": [
    "blocks",
    "directory"
  ],
  "type": "registry:block"
}