{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typing-indicator",
  "title": "Typing indicator",
  "author": "Tori Bryan",
  "description": "Says who's typing in a conversation, naming up to three people.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/typing-indicator.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype TypingPerson = {\n  /** Stable id, compared with `currentUserId`. */\n  id: string\n  /** Display name, as it should read in the sentence. */\n  name: string\n  /** Replaces the dots while this person is the only one typing. Held to 16px tall. */\n  indicator?: React.ReactNode\n}\n\ntype TypingSentence = {\n  /** Everyone typing, the current person left out. */\n  count: number\n  /** The names to show, at most `maxNames` of them. Empty past that. */\n  names: string[]\n  /** `names` joined for the locale, such as \"Ana, Ben, and Cy\". */\n  list: string\n}\n\ntype TypingFormatter = (sentence: TypingSentence) => string\n\n/*\n * One whole sentence per case, never \"x\" + \" and \" + \"y\": other languages\n * change the verb, the word order and the plural form with the count, so a\n * translator needs the full sentence for each. `list` comes from\n * Intl.ListFormat, which already knows each locale's \"and\".\n */\nconst formatTyping: TypingFormatter = ({ count, names, list }) => {\n  if (count === 0) return \"\"\n  if (names.length === 0) return \"Several people are typing…\"\n  return count === 1 ? `${list} is typing…` : `${list} are typing…`\n}\n\nfunction shorten(name: string, limit: number) {\n  // Room for at least one letter before the ellipsis.\n  const max = Math.max(2, limit)\n  const chars = Array.from(name)\n  return chars.length > max\n    ? `${chars\n        .slice(0, max - 1)\n        .join(\"\")\n        .trimEnd()}…`\n    : name\n}\n\n/*\n * Screen readers hear the sentence once it has held still for `delay`, so a\n * burst of people starting and stopping reads as one update instead of a\n * stream. Clearing is immediate and silent: an emptied status region isn't\n * announced, and nobody needs telling that a person stopped typing.\n */\nfunction useSettled(value: string, delay: number) {\n  const [settled, setSettled] = React.useState(\"\")\n  React.useEffect(() => {\n    // An empty value resets at once too, so a stale sentence can't flash\n    // back when someone starts typing again.\n    const id = window.setTimeout(() => setSettled(value), value ? delay : 0)\n    return () => window.clearTimeout(id)\n  }, [value, delay])\n  return value ? settled : \"\"\n}\n\ntype TypingIndicatorProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Everyone typing right now. Pass the current person too; they're left out. */\n  people: TypingPerson[]\n  /** The id of the person looking at the screen, who never sees themselves typing. */\n  currentUserId?: string\n  /** How many people to name before switching to \"Several people\". */\n  maxNames?: number\n  /** Characters a name may take before it's cut short on screen, at least 2. Announcements keep it whole. */\n  maxNameLength?: number\n  /** Builds the sentence. Swap it to translate; the default is English. */\n  format?: TypingFormatter\n  /** Locale for joining names, passed to Intl.ListFormat. */\n  locale?: string\n  /** Milliseconds the sentence must hold still before screen readers hear it. */\n  announceDelay?: number\n}\n\nfunction TypingIndicator({\n  people,\n  currentUserId,\n  maxNames = 3,\n  maxNameLength = 24,\n  format = formatTyping,\n  locale = \"en\",\n  announceDelay = 1500,\n  className,\n  ...props\n}: TypingIndicatorProps) {\n  // One person typing on two devices sends two events; they're still one name.\n  const typing = [\n    ...new Map(\n      people\n        .filter((person) => person.id !== currentUserId)\n        .map((person) => [person.id, person])\n    ).values(),\n  ]\n  const count = typing.length\n  const named = count <= maxNames ? typing : []\n\n  const join = React.useMemo(\n    () => new Intl.ListFormat(locale, { type: \"conjunction\" }),\n    [locale]\n  )\n  const sentence = (names: string[]) =>\n    format({ count, names, list: join.format(names) })\n\n  const shown = sentence(named.map((p) => shorten(p.name, maxNameLength)))\n  const spoken = useSettled(sentence(named.map((p) => p.name)), announceDelay)\n\n  const custom = count === 1 ? typing[0]?.indicator : undefined\n\n  return (\n    <div\n      data-slot=\"typing-indicator\"\n      data-active={count > 0 ? \"\" : undefined}\n      className={cn(\n        // A fixed height that's there even when nobody is typing, so the\n        // message list above doesn't jump each time someone starts.\n        \"flex h-6 min-w-0 items-center gap-2 text-xs text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {count > 0 ? (\n        <div aria-hidden className=\"flex min-w-0 items-center gap-2\">\n          {/* A fixed height rather than a cap, so the dots have room to\n              rise inside the clip that holds a custom indicator to 16px. */}\n          <span\n            data-slot=\"typing-indicator-dots\"\n            className=\"flex h-4 shrink-0 items-center gap-0.5 overflow-hidden [&_svg]:size-3.5\"\n          >\n            {custom ?? <TypingDots />}\n          </span>\n          <span data-slot=\"typing-indicator-text\" className=\"truncate\">\n            {shown}\n          </span>\n        </div>\n      ) : null}\n      <span\n        role=\"status\"\n        data-slot=\"typing-indicator-status\"\n        className=\"sr-only\"\n      >\n        {spoken}\n      </span>\n    </div>\n  )\n}\n\nfunction TypingDots() {\n  return [0, 1, 2].map((i) => (\n    <span\n      key={i}\n      className=\"size-1 animate-typing-dot rounded-full bg-current motion-reduce:animate-none\"\n      style={{ animationDelay: `${i * 150}ms` }}\n    />\n  ))\n}\n\nexport {\n  TypingIndicator,\n  formatTyping,\n  type TypingIndicatorProps,\n  type TypingPerson,\n  type TypingSentence,\n  type TypingFormatter,\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-typing-dot": "typing-dot 1.2s ease-in-out infinite"
    }
  },
  "css": {
    "@keyframes typing-dot": {
      "0%,\n    60%,\n    100%": {
        "opacity": "0.4",
        "transform": "translateY(0)"
      },
      "30%": {
        "opacity": "1",
        "transform": "translateY(-2px)"
      }
    }
  },
  "meta": {
    "tier": "base-components",
    "group": "Feedback",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-feedback-typing-indicator--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-feedback-typing-indicator--docs",
  "categories": [
    "base-components",
    "feedback"
  ],
  "type": "registry:ui"
}