{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-emoji-picker-5","type":"registry:block","title":"Custom team emoji","description":"A release thread whose reply field inserts the workspace's own emoji as shortcodes from their own tab.","dependencies":[],"registryDependencies":["avatar","button","card","@reui/emoji-picker","input-group","item","separator"],"files":[{"path":"c-emoji-picker-5.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useRef, useState } from \"react\"\nimport {\n  EMOJI_PICKER_CATEGORIES,\n  EmojiPicker,\n  EmojiPickerContent,\n  EmojiPickerTrigger,\n  type EmojiPickerCustomCategory,\n  type EmojiPickerCustomEmoji,\n} from \"@/components/reui/emoji-picker\"\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\nimport {\n  Item,\n  ItemContent,\n  ItemMedia,\n  ItemTitle,\n} from \"@/components/ui/item\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/* Inline SVG so the example ships no image files. In an app these are the\n   workspace's uploads, and any image URL works as `src`. One font size keeps\n   one cap height; 4-letter labels are fitted to 44 of the 64 units, so a\n   wider system font (Segoe UI, Roboto) cannot push them into the corners. */\nconst glyph = (text: string, color: string, size = 18) =>\n  `data:image/svg+xml,${encodeURIComponent(\n    `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"16\" fill=\"${color}\"/><text x=\"32\" y=\"${32 + size * 0.35}\" font-family=\"system-ui,sans-serif\" font-size=\"${size}\" font-weight=\"800\" letter-spacing=\"-0.5\" fill=\"#fff\" text-anchor=\"middle\"${text.length >= 4 ? ' textLength=\"44\" lengthAdjust=\"spacingAndGlyphs\"' : \"\"}>${text}</text></svg>`\n  )}`\n\n/* Emoji whose `category` matches a `customCategories` id get their own tab,\n   named by its label. The workspace logo replaces the default sparkle, the\n   way Discord marks each server's set. */\nconst WORKSPACE: EmojiPickerCustomCategory = {\n  id: \"northwind\",\n  label: \"Northwind Labs\",\n  icon: (\n    <img\n      src={glyph(\"N\", \"#4f46e5\", 40)}\n      alt=\"\"\n      width={16}\n      height={16}\n      className=\"size-4\"\n    />\n  ),\n}\n\n/* The team's set leads, ahead of Frequently used, so it opens first however\n   much the picker has been used. */\nconst CATEGORIES = [WORKSPACE.id, \"recent\", ...EMOJI_PICKER_CATEGORIES]\n\n/* Two full rows of the default 8 columns, so the set opens as a block of its\n   own above Smileys. The id doubles as the shortcode: `:lgtm:`. Where the\n   tile prints another word, that word is a second shortcode, so it ties the\n   native emoji of that name and the team's set, listed first, wins; a tag\n   ranks below a name match. Only the id is stored and rendered, so a typed\n   `:ship:` stays text and `:+1:` (a tag) stays the thumbs up. */\nconst TEAM_EMOJI: EmojiPickerCustomEmoji[] = [\n  { id: \"lgtm\", label: \"Looks good to me\", src: glyph(\"LGTM\", \"#16a34a\") },\n  {\n    id: \"shipit\",\n    label: \"Ship it\",\n    src: glyph(\"SHIP\", \"#7c3aed\"),\n    shortcodes: [\"shipit\", \"ship\"],\n  },\n  {\n    id: \"plusone\",\n    label: \"Plus one\",\n    src: glyph(\"+1\", \"#2563eb\"),\n    tags: [\"+1\", \"agree\", \"yes\"],\n  },\n  { id: \"wip\", label: \"Work in progress\", src: glyph(\"WIP\", \"#d97706\") },\n  {\n    id: \"blocked\",\n    label: \"Blocked\",\n    src: glyph(\"STOP\", \"#dc2626\"),\n    shortcodes: [\"blocked\", \"stop\"],\n  },\n  {\n    id: \"ty\",\n    label: \"Thank you\",\n    src: glyph(\"TY\", \"#db2777\"),\n    tags: [\"thanks\"],\n  },\n  { id: \"ack\", label: \"Acknowledged\", src: glyph(\"ACK\", \"#475569\") },\n  {\n    id: \"hotfix\",\n    label: \"Hotfix\",\n    src: glyph(\"FIX\", \"#ea580c\"),\n    shortcodes: [\"hotfix\", \"fix\"],\n  },\n  { id: \"nit\", label: \"Nitpick\", src: glyph(\"NIT\", \"#0891b2\") },\n  { id: \"sync\", label: \"Let's sync\", src: glyph(\"SYNC\", \"#65a30d\") },\n  {\n    id: \"revert\",\n    label: \"Revert\",\n    src: glyph(\"UNDO\", \"#c026d3\"),\n    shortcodes: [\"revert\", \"undo\"],\n  },\n  {\n    id: \"live\",\n    label: \"Live in production\",\n    src: glyph(\"LIVE\", \"#0284c7\"),\n    tags: [\"deployed\"],\n  },\n  { id: \"brb\", label: \"Be right back\", src: glyph(\"BRB\", \"#ca8a04\") },\n  {\n    id: \"looking\",\n    label: \"Taking a look\",\n    src: glyph(\"LOOK\", \"#0d9488\"),\n    shortcodes: [\"looking\", \"look\"],\n    tags: [\"eyes\"],\n  },\n  { id: \"help\", label: \"Need a hand\", src: glyph(\"HELP\", \"#e11d48\") },\n  { id: \"qa\", label: \"Ready for QA\", src: glyph(\"QA\", \"#9333ea\") },\n].map((emoji) => ({ ...emoji, category: WORKSPACE.id }))\n\nconst BY_CODE = new Map(TEAM_EMOJI.map((emoji) => [`:${emoji.id}:`, emoji]))\n\n/* Custom emoji travel as `:shortcode:` in the stored text and become images\n   on render, the way Slack and Discord keep them. `h-lh` fills the line\n   box each style sets (20px on nova, 16px on lyra) and the width follows\n   the square intrinsic size, so the line keeps its height. */\nfunction renderText(text: string) {\n  return text.split(/(:[a-z0-9_+-]+:)/g).map((part, index) => {\n    const emoji = BY_CODE.get(part)\n    return emoji ? (\n      <img\n        key={index}\n        src={emoji.src}\n        alt={emoji.label}\n        title={part}\n        width={20}\n        height={20}\n        className=\"inline-block h-lh w-auto align-top\"\n      />\n    ) : (\n      part\n    )\n  })\n}\n\ntype Author = { name: string; initials: string; avatar: string }\n\ntype Post = { id: string; author: Author; time: string; text: string }\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 MICHAEL: Author = {\n  name: \"Michael Rodriguez\",\n  initials: \"MR\",\n  avatar: photo(\"1500648767791-00dcc994a43e\"),\n}\nconst DANIEL: Author = {\n  name: \"Daniel Kim\",\n  initials: \"DK\",\n  avatar: photo(\"1535713875002-d1d0cf377fde\"),\n}\nconst PRIYA: Author = {\n  name: \"Priya Shah\",\n  initials: \"PS\",\n  avatar: photo(\"1584308972272-9e4e7685e80f\"),\n}\nconst ME: Author = {\n  name: \"Mira Stone\",\n  initials: \"MS\",\n  avatar: photo(\"1494790108377-be9c29b29330\"),\n}\n\nconst ROOT: Post = {\n  id: \"root\",\n  author: AMARA,\n  time: \"9:36 AM\",\n  text: \"Release candidate is on staging :shipit: and one flaky checkout spec is left :wip:\",\n}\n\nconst REPLIES: Post[] = [\n  {\n    id: \"r1\",\n    author: MICHAEL,\n    time: \"9:48 AM\",\n    text: \"Pinned the payment mock :hotfix:\",\n  },\n  {\n    id: \"r2\",\n    author: DANIEL,\n    time: \"9:52 AM\",\n    text: \"Green on all three browsers :lgtm:\",\n  },\n  {\n    id: \"r3\",\n    author: PRIYA,\n    time: \"9:55 AM\",\n    text: \"Release notes are merged :ty: :plusone:\",\n  },\n]\n\nfunction PostItem({\n  post,\n  variant,\n}: {\n  post: Post\n  variant?: \"default\" | \"muted\"\n}) {\n  return (\n    <Item variant={variant}>\n      {/* An empty alt: the name sits beside the photo, so a screen reader\n          would otherwise read it twice. Top-aligned by hand, since Item only\n          does that when it finds an ItemDescription. */}\n      <ItemMedia className=\"self-start\">\n        <Avatar>\n          <AvatarImage src={post.author.avatar} alt=\"\" />\n          <AvatarFallback>{post.author.initials}</AvatarFallback>\n        </Avatar>\n      </ItemMedia>\n      <ItemContent>\n        <ItemTitle>\n          {post.author.name}\n          <span\n            aria-hidden=\"true\"\n            className=\"bg-muted-foreground/40 size-1 shrink-0 rounded-full\"\n          />\n          <span className=\"text-muted-foreground font-normal\">{post.time}</span>\n        </ItemTitle>\n        {/* Not ItemDescription, which clamps to two lines: a thread shows\n            every message whole, the one you just sent included. */}\n        <p className=\"text-pretty wrap-break-word\">{renderText(post.text)}</p>\n      </ItemContent>\n    </Item>\n  )\n}\n\nexport function Pattern() {\n  const [replies, setReplies] = useState(REPLIES)\n  const [text, setText] = useState(\"\")\n  const inputRef = useRef<HTMLInputElement>(null)\n  const empty = !text.trim()\n\n  /* A blurred input keeps its selection, so the shortcode replaces it and\n     the caret lands after it. Padding it with spaces keeps it a token of its\n     own: in `at 10:30:ty:` the time would swallow the opening colon. */\n  const insert = (code: string) => {\n    const field = inputRef.current\n    if (!field) return\n    const { value, selectionStart, selectionEnd } = field\n    const start = selectionStart ?? value.length\n    const before = value.slice(0, start)\n    const after = value.slice(selectionEnd ?? start)\n    const chunk = `${before && !before.endsWith(\" \") ? \" \" : \"\"}${code}${after.startsWith(\" \") ? \"\" : \" \"}`\n    const caret = start + chunk.length + (after.startsWith(\" \") ? 1 : 0)\n    const next = before + chunk + after\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. Focus goes back to the field for the next reply. */\n  const send = () => {\n    if (empty) return\n    setReplies([\n      ...replies,\n      {\n        id: `r${replies.length + 1}`,\n        author: ME,\n        time: \"Just now\",\n        text: text.trim(),\n      },\n    ])\n    setText(\"\")\n    inputRef.current?.focus()\n  }\n\n  return (\n    <Card className=\"w-full max-w-md\">\n      <CardHeader>\n        <CardTitle>Release 4.2</CardTitle>\n        <CardDescription>#release-train</CardDescription>\n      </CardHeader>\n      <CardContent>\n        <div className=\"flex flex-col gap-3\">\n          <PostItem post={ROOT} variant=\"muted\" />\n          {/* The rows bring their own padding, so a tight gap here keeps\n              the label as far from the first reply as from the root. */}\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"flex items-center gap-3\">\n              <span className=\"text-muted-foreground shrink-0 text-xs\">\n                {replies.length} {replies.length === 1 ? \"reply\" : \"replies\"}\n              </span>\n              {/* Separator is shrink-0 at full width, so a flex-1 wrapper\n                gives it the room the label leaves. */}\n              <div className=\"flex-1\">\n                <Separator />\n              </div>\n            </div>\n            {/* A log announces each reply as it lands, so a send needs no\n              separate status region. */}\n            <div role=\"log\" aria-label=\"Replies\" className=\"flex flex-col\">\n              {replies.map((reply) => (\n                <PostItem key={reply.id} post={reply} />\n              ))}\n            </div>\n          </div>\n        </div>\n      </CardContent>\n      <CardFooter>\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=\"Reply\"\n            aria-label=\"Reply to thread\"\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. Recent picks are\n                kept per workspace, since another workspace's `:shortcode:`\n                means nothing here. */}\n            <EmojiPicker\n              value={null}\n              aria-label=\"Add emoji\"\n              customEmojis={TEAM_EMOJI}\n              customCategories={[WORKSPACE]}\n              categories={CATEGORIES}\n              recentStorageKey=\"northwind-emoji-recent\"\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 thread 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 field. An empty send is a no-op that says so, and the\n                button fills once there is a draft. It stays mounted rather\n                than appearing with the draft, so the emoji button never\n                jumps under the pointer. */}\n            <Button\n              variant={empty ? \"ghost\" : \"default\"}\n              size=\"icon-sm\"\n              aria-label=\"Send reply\"\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      </CardFooter>\n    </Card>\n  )\n}","target":"components/examples/c-emoji-picker-5.tsx"}],"meta":{"order":5,"label":"Custom Team Emoji"}}