{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-emoji-picker-2","type":"registry:block","title":"Chat composer","description":"A chat composer that inserts emoji at the caret and keeps the draft in view.","dependencies":[],"registryDependencies":["avatar","bubble","button","card","@reui/emoji-picker","input-group","message","separator"],"files":[{"path":"c-emoji-picker-2.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport {\n  EmojiPicker,\n  EmojiPickerContent,\n  EmojiPickerTrigger,\n} from \"@/components/reui/emoji-picker\"\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarGroup,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport {\n  Bubble,\n  BubbleContent,\n  BubbleGroup,\n} from \"@/components/ui/bubble\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\nimport {\n  Message,\n  MessageAvatar,\n  MessageContent,\n  MessageHeader,\n} from \"@/components/ui/message\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\ntype Author = { name: string; initials: string; avatar: string }\n\ntype ChatGroup = {\n  id: string\n  /* No author means the group is yours: end-aligned, primary, no avatar. */\n  author?: Author\n  time: string\n  lines: string[]\n}\n\nconst photo = (id: string) =>\n  `https://images.unsplash.com/photo-${id}?w=96&h=96&dpr=2&q=80`\n\nconst AMARA: Author = {\n  name: \"Amara Okafor\",\n  initials: \"AO\",\n  avatar: photo(\"1519699047748-de8e457a634e\"),\n}\nconst LUCAS: Author = {\n  name: \"Lucas Martin\",\n  initials: \"LM\",\n  avatar: photo(\"1527980965255-d3b416303d12\"),\n}\nconst PRIYA: Author = {\n  name: \"Priya Shah\",\n  initials: \"PS\",\n  avatar: photo(\"1584308972272-9e4e7685e80f\"),\n}\nconst MIRA: Author = {\n  name: \"Mira Stone\",\n  initials: \"MS\",\n  avatar: photo(\"1494790108377-be9c29b29330\"),\n}\n\nconst MEMBERS = [AMARA, LUCAS, PRIYA, MIRA]\n\nconst THREAD: ChatGroup[] = [\n  {\n    id: \"g0\",\n    author: PRIYA,\n    time: \"9:05 AM\",\n    lines: [\"Morning! Slides are in the shared drive.\"],\n  },\n  {\n    id: \"g1\",\n    author: AMARA,\n    time: \"9:12 AM\",\n    lines: [\"Launch review moved to 4 PM.\", \"Can everyone still make it?\"],\n  },\n  {\n    id: \"g2\",\n    author: LUCAS,\n    time: \"9:14 AM\",\n    lines: [\"Works for me 👍\"],\n  },\n  {\n    id: \"g3\",\n    time: \"9:15 AM\",\n    lines: [\"I'm in. Demo build is ready.\"],\n  },\n  {\n    id: \"g4\",\n    author: PRIYA,\n    time: \"9:21 AM\",\n    lines: [\"Same. Big room is booked.\"],\n  },\n]\n\nexport function Pattern() {\n  const [groups, setGroups] = useState(THREAD)\n  const [text, setText] = useState(\"\")\n  const logRef = useRef<HTMLDivElement>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const empty = !text.trim()\n\n  /* Keyed on the array, not its length: a follow-up joins your last group\n     and leaves the length unchanged. */\n  useEffect(() => {\n    const log = logRef.current\n    if (log) log.scrollTop = log.scrollHeight\n  }, [groups])\n\n  /* A blurred input keeps its selection, so the emoji replaces it and the\n     caret lands after it. Set here rather than after focus returns, because\n     a Shift+click pick keeps the picker open. */\n  const insert = (emoji: string) => {\n    const field = inputRef.current\n    if (!field) return\n    const { value } = field\n    const start = field.selectionStart ?? value.length\n    const end = field.selectionEnd ?? start\n    const next = value.slice(0, start) + emoji + value.slice(end)\n    const caret = start + emoji.length\n    field.value = next\n    field.setSelectionRange(caret, caret)\n    setText(next)\n  }\n\n  /* A fixed label, not a clock read, so the server render and the first\n     client render agree. */\n  const send = () => {\n    if (empty) return\n    const line = text.trim()\n    const last = groups.at(-1)\n    setGroups(\n      last && !last.author\n        ? [...groups.slice(0, -1), { ...last, lines: [...last.lines, line] }]\n        : [\n            ...groups,\n            { id: `g${groups.length + 1}`, time: \"Just now\", lines: [line] },\n          ]\n    )\n    setText(\"\")\n    inputRef.current?.focus()\n  }\n\n  return (\n    <Card className=\"w-full max-w-md\">\n      <CardHeader>\n        <CardTitle>#launch-crew</CardTitle>\n        <CardDescription>4 members</CardDescription>\n        <CardAction>\n          {/* Decorative: the description already gives the count, and four\n              names read out before the thread would only be noise. */}\n          <AvatarGroup aria-hidden=\"true\">\n            {MEMBERS.map((member) => (\n              <Avatar key={member.name} size=\"sm\">\n                <AvatarImage src={member.avatar} alt=\"\" />\n                <AvatarFallback>{member.initials}</AvatarFallback>\n              </Avatar>\n            ))}\n          </AvatarGroup>\n        </CardAction>\n      </CardHeader>\n      {/* One wrapper, so no Card gap falls between the rule and the\n          scroller: once the thread outgrows its cap, older messages scroll\n          under the rule itself. */}\n      <div>\n        <Separator />\n        {/* The seeded thread fits under the cap in every style, so it opens\n            whole; sends grow it until it scrolls. A log announces each\n            message as it lands, and it is focusable so Safari keyboard users\n            can scroll back through the history. */}\n        <CardContent\n          ref={logRef}\n          role=\"log\"\n          aria-label=\"Messages in #launch-crew\"\n          tabIndex={0}\n          className=\"max-h-160 overflow-y-auto\"\n        >\n          <div className=\"flex flex-col gap-6 pt-4\">\n            {groups.map((group) => (\n              <Message key={group.id} align={group.author ? \"start\" : \"end\"}>\n                {group.author && (\n                  /* Top-aligned beside the name line, so every group reads\n                   the same way: who, when, then what. The header names the\n                   sender, so the photo stays silent. */\n                  <MessageAvatar aria-hidden=\"true\" className=\"self-start\">\n                    <Avatar>\n                      <AvatarImage src={group.author.avatar} alt=\"\" />\n                      <AvatarFallback>{group.author.initials}</AvatarFallback>\n                    </Avatar>\n                  </MessageAvatar>\n                )}\n                <MessageContent>\n                  <MessageHeader>\n                    <span className=\"flex items-center gap-1.5\">\n                      {group.author ? (\n                        <>\n                          <span className=\"text-foreground\">\n                            {group.author.name}\n                          </span>\n                          <span\n                            aria-hidden=\"true\"\n                            className=\"bg-muted-foreground/40 size-1 shrink-0 rounded-full\"\n                          />\n                        </>\n                      ) : (\n                        /* Your own groups show only the time, as chat apps\n                         do. The hidden word keeps the log from announcing\n                         a bare time. */\n                        <span className=\"sr-only\">You</span>\n                      )}\n                      <span className=\"font-normal\">{group.time}</span>\n                    </span>\n                  </MessageHeader>\n                  {/* An end row shrinks to fit, so the bubbles' 80% cap would\n                    resolve against the group and wrap a short line. */}\n                  <BubbleGroup className=\"w-full\">\n                    {group.lines.map((line, index) => (\n                      <Bubble\n                        key={index}\n                        variant={group.author ? \"muted\" : \"default\"}\n                      >\n                        <BubbleContent>{line}</BubbleContent>\n                      </Bubble>\n                    ))}\n                  </BubbleGroup>\n                </MessageContent>\n              </Message>\n            ))}\n          </div>\n        </CardContent>\n      </div>\n      <CardContent>\n        <InputGroup>\n          <InputGroupInput\n            ref={inputRef}\n            value={text}\n            onChange={(event) => setText(event.target.value)}\n            onKeyDown={(event) => {\n              /* Enter that confirms an IME composition is not a send. */\n              if (event.key === \"Enter\" && !event.nativeEvent.isComposing)\n                send()\n            }}\n            placeholder=\"Message #launch-crew\"\n            aria-label=\"Message #launch-crew\"\n          />\n          <InputGroupAddon align=\"inline-end\">\n            {/* Controlled to null so the trigger keeps its smiley: this\n                button inserts, it does not hold a value. */}\n            <EmojiPicker\n              value={null}\n              aria-label=\"Add emoji\"\n              onEmojiSelect={({ value }) => insert(value)}\n            >\n              <EmojiPickerTrigger variant=\"ghost\" size=\"icon-sm\" />\n              {/* Above the field so the draft stays in view; it flips below\n                  when the page leaves no room. */}\n              <EmojiPickerContent\n                side=\"top\"\n                align=\"end\"\n                returnFocus={inputRef}\n              />\n            </EmojiPicker>\n            {/* Never `disabled`: inside an InputGroup that greys out the\n                whole composer. An empty send is a no-op that says so, and\n                the button fills once there is a draft. */}\n            <Button\n              variant={empty ? \"ghost\" : \"default\"}\n              size=\"icon-sm\"\n              aria-label=\"Send message\"\n              aria-disabled={empty}\n              onClick={send}\n            >\n              <IconPlaceholder\n                lucide=\"SendIcon\"\n                tabler=\"IconSend\"\n                hugeicons=\"SentIcon\"\n                phosphor=\"PaperPlaneRightIcon\"\n                remixicon=\"RiSendPlaneLine\"\n                aria-hidden=\"true\"\n              />\n            </Button>\n          </InputGroupAddon>\n        </InputGroup>\n      </CardContent>\n    </Card>\n  )\n}","target":"components/examples/c-emoji-picker-2.tsx"}],"meta":{"order":2,"label":"Chat Composer"}}