{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"emoji-select","type":"registry:ui","title":"EmojiSelect","description":"EmojiSelect from react-fancy","package":"react-fancy","dependencies":[],"registryDependencies":[],"files":[{"path":"components\/fancy\/emoji-select\/EmojiSelect.tsx","content":"import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\r\nimport { createPortal } from \"react-dom\";\r\nimport { cn } from \"..\/..\/utils\/cn\";\r\nimport { useControllableState } from \"..\/..\/hooks\/use-controllable-state\";\r\nimport { EMOJI_CATEGORY_ORDER, EMOJI_DATA, type EmojiCategoryKey, type EmojiEntry } from \"..\/..\/data\/emoji-data\";\r\nimport { applyTone, SKIN_TONES, type SkinTone } from \"..\/..\/data\/emoji-utils\";\r\nimport type { EmojiSelectProps } from \".\/EmojiSelect.types\";\r\n\r\nconst TONE_STORAGE_KEY = \"fancy:emoji-tone\";\r\nconst LONG_PRESS_MS = 400;\r\nconst HOVER_INTENT_MS = 120;\r\nconst HOVER_LEAVE_GRACE_MS = 220;\r\n\r\ntype ToneTarget = {\r\n  emoji: EmojiEntry;\r\n  rect: { left: number; top: number; width: number };\r\n};\r\n\r\n\/\/ Deliberately no hover-capability gate. We use `pointerType` on pointer events so touch\r\n\/\/ inputs never trigger the hover path (they go through long-press instead), and mouse\/pen\r\n\/\/ inputs always work regardless of what `(hover: hover)` resolves to in the current env.\r\n\r\nfunction readStoredTone(): SkinTone | null {\r\n  if (typeof window === \"undefined\") return null;\r\n  try {\r\n    const v = window.localStorage?.getItem(TONE_STORAGE_KEY);\r\n    return (SKIN_TONES as readonly string[]).includes(v ?? \"\") ? (v as SkinTone) : null;\r\n  } catch {\r\n    return null;\r\n  }\r\n}\r\n\r\nexport function EmojiSelect({ value, defaultValue, onChange, placeholder = \"Search emojis...\", className }: EmojiSelectProps) {\r\n  const [selected, setSelected] = useControllableState(value, defaultValue ?? \"\", onChange);\r\n  const [open, setOpen] = useState(false);\r\n  const [query, setQuery] = useState(\"\");\r\n  const [activeCategory, setActiveCategory] = useState<EmojiCategoryKey>(\"smileys\");\r\n  const [tone, setToneState] = useState<SkinTone | null>(() => readStoredTone());\r\n  const [toneTarget, setToneTarget] = useState<ToneTarget | null>(null);\r\n  const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\r\n\r\n  const setTone = useCallback((t: SkinTone | null) => {\r\n    setToneState(t);\r\n    if (typeof window === \"undefined\") return;\r\n    try {\r\n      if (t) window.localStorage?.setItem(TONE_STORAGE_KEY, t);\r\n      else window.localStorage?.removeItem(TONE_STORAGE_KEY);\r\n    } catch {}\r\n  }, []);\r\n\r\n  const filtered = useMemo(() => {\r\n    if (!query) return null;\r\n    const lower = query.toLowerCase();\r\n    const hits: EmojiEntry[] = [];\r\n    for (const group of Object.values(EMOJI_DATA)) {\r\n      for (const e of group.emojis) {\r\n        if (e.name.includes(lower)) hits.push(e);\r\n      }\r\n    }\r\n    return hits;\r\n  }, [query]);\r\n\r\n  const closePicker = useCallback(() => setToneTarget(null), []);\r\n\r\n  const openPicker = useCallback((target: ToneTarget) => {\r\n    if (leaveTimer.current) {\r\n      clearTimeout(leaveTimer.current);\r\n      leaveTimer.current = null;\r\n    }\r\n    setToneTarget(target);\r\n  }, []);\r\n\r\n  const scheduleClose = useCallback(() => {\r\n    if (leaveTimer.current) clearTimeout(leaveTimer.current);\r\n    leaveTimer.current = setTimeout(closePicker, HOVER_LEAVE_GRACE_MS);\r\n  }, [closePicker]);\r\n\r\n  const cancelClose = useCallback(() => {\r\n    if (leaveTimer.current) {\r\n      clearTimeout(leaveTimer.current);\r\n      leaveTimer.current = null;\r\n    }\r\n  }, []);\r\n\r\n  const pick = (emoji: EmojiEntry) => {\r\n    const char = emoji.skinTones && tone ? applyTone(emoji.char, tone) : emoji.char;\r\n    setSelected(char);\r\n    setOpen(false);\r\n    setQuery(\"\");\r\n    closePicker();\r\n  };\r\n\r\n  const pickTone = (emoji: EmojiEntry, t: SkinTone) => {\r\n    setTone(t);\r\n    const char = applyTone(emoji.char, t);\r\n    setSelected(char);\r\n    setOpen(false);\r\n    setQuery(\"\");\r\n    closePicker();\r\n  };\r\n\r\n  useEffect(() => {\r\n    if (!open) closePicker();\r\n  }, [open, closePicker]);\r\n\r\n  return (\r\n    <div className={cn(\"relative inline-block\", className)} data-react-fancy-emoji-select=\"\">\r\n      <button\r\n        type=\"button\"\r\n        className=\"flex items-center gap-2 rounded-lg border border-zinc-300 px-3 py-2 text-sm transition-[border-color,box-shadow] duration-150 dark:border-zinc-700 dark:bg-[#1e1e24]\"\r\n        onClick={() => setOpen((v) => !v)}\r\n      >\r\n        {selected ? <span className=\"text-xl\">{selected}<\/span> : <span className=\"text-zinc-400\">Pick emoji<\/span>}\r\n      <\/button>\r\n\r\n      {open && (\r\n        <div className=\"absolute z-50 mt-1 w-80 rounded-lg border border-zinc-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-800 fancy-fade-in\">\r\n          <input\r\n            type=\"text\"\r\n            value={query}\r\n            onChange={(e) => setQuery(e.target.value)}\r\n            placeholder={placeholder}\r\n            className=\"mb-2 w-full rounded-md border border-zinc-200 px-2 py-1 text-sm transition-[border-color,box-shadow] duration-150 dark:border-zinc-700 dark:bg-[#1e1e24] dark:text-zinc-100\"\r\n            autoFocus\r\n          \/>\r\n\r\n          {!query && (\r\n            <div className=\"mb-2 flex gap-1 overflow-x-auto border-b border-zinc-100 pb-2 dark:border-zinc-700\">\r\n              {EMOJI_CATEGORY_ORDER.map((key) => (\r\n                <button\r\n                  key={key}\r\n                  type=\"button\"\r\n                  onClick={() => setActiveCategory(key)}\r\n                  className={cn(\r\n                    \"shrink-0 rounded p-1 text-lg hover:bg-zinc-100 dark:hover:bg-zinc-700\",\r\n                    activeCategory === key && \"bg-zinc-100 dark:bg-zinc-700\",\r\n                  )}\r\n                  aria-label={EMOJI_DATA[key].label}\r\n                >\r\n                  {EMOJI_DATA[key].icon}\r\n                <\/button>\r\n              ))}\r\n            <\/div>\r\n          )}\r\n\r\n          <div className=\"max-h-64 overflow-y-auto\">\r\n            {query ? (\r\n              <EmojiGrid\r\n                emojis={filtered ?? []}\r\n                tone={tone}\r\n                openPicker={openPicker}\r\n                scheduleClose={scheduleClose}\r\n                cancelClose={cancelClose}\r\n                onPick={pick}\r\n              \/>\r\n            ) : (\r\n              <div>\r\n                <div className=\"px-1 py-1 text-xs font-semibold uppercase text-zinc-500\">\r\n                  {EMOJI_DATA[activeCategory].label}\r\n                <\/div>\r\n                <EmojiGrid\r\n                  emojis={EMOJI_DATA[activeCategory].emojis}\r\n                  tone={tone}\r\n                  openPicker={openPicker}\r\n                  scheduleClose={scheduleClose}\r\n                  cancelClose={cancelClose}\r\n                  onPick={pick}\r\n                \/>\r\n              <\/div>\r\n            )}\r\n          <\/div>\r\n        <\/div>\r\n      )}\r\n\r\n      {toneTarget && typeof document !== \"undefined\" &&\r\n        createPortal(\r\n          <TonePopover\r\n            target={toneTarget}\r\n            onPickBase={() => pick(toneTarget.emoji)}\r\n            onPickTone={(t) => pickTone(toneTarget.emoji, t)}\r\n            onMouseEnter={cancelClose}\r\n            onMouseLeave={scheduleClose}\r\n          \/>,\r\n          document.body,\r\n        )}\r\n    <\/div>\r\n  );\r\n}\r\n\r\ntype GridProps = {\r\n  emojis: EmojiEntry[];\r\n  tone: SkinTone | null;\r\n  openPicker: (target: ToneTarget) => void;\r\n  scheduleClose: () => void;\r\n  cancelClose: () => void;\r\n  onPick: (emoji: EmojiEntry) => void;\r\n};\r\n\r\nfunction EmojiGrid({ emojis, tone, openPicker, scheduleClose, cancelClose, onPick }: GridProps) {\r\n  return (\r\n    <div className=\"grid grid-cols-8 gap-0.5\">\r\n      {emojis.map((emoji) => (\r\n        <EmojiCell\r\n          key={emoji.char}\r\n          emoji={emoji}\r\n          tone={tone}\r\n          openPicker={openPicker}\r\n          scheduleClose={scheduleClose}\r\n          cancelClose={cancelClose}\r\n          onPick={() => onPick(emoji)}\r\n        \/>\r\n      ))}\r\n    <\/div>\r\n  );\r\n}\r\n\r\ntype CellProps = {\r\n  emoji: EmojiEntry;\r\n  tone: SkinTone | null;\r\n  openPicker: (target: ToneTarget) => void;\r\n  scheduleClose: () => void;\r\n  cancelClose: () => void;\r\n  onPick: () => void;\r\n};\r\n\r\nfunction EmojiCell({ emoji, tone, openPicker, scheduleClose, cancelClose, onPick }: CellProps) {\r\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\r\n  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\r\n  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\r\n  const longPressed = useRef(false);\r\n\r\n  const tonable = !!emoji.skinTones;\r\n  const displayChar = tonable && tone ? applyTone(emoji.char, tone) : emoji.char;\r\n\r\n  useEffect(() => () => {\r\n    if (longPressTimer.current) clearTimeout(longPressTimer.current);\r\n    if (hoverTimer.current) clearTimeout(hoverTimer.current);\r\n  }, []);\r\n\r\n  const triggerOpen = () => {\r\n    const node = buttonRef.current;\r\n    if (!node) return;\r\n    const r = node.getBoundingClientRect();\r\n    openPicker({ emoji, rect: { left: r.left, top: r.top, width: r.width } });\r\n  };\r\n\r\n  const handlePointerEnter = (e: React.PointerEvent) => {\r\n    if (!tonable || e.pointerType === \"touch\") return;\r\n    cancelClose();\r\n    if (hoverTimer.current) clearTimeout(hoverTimer.current);\r\n    hoverTimer.current = setTimeout(triggerOpen, HOVER_INTENT_MS);\r\n  };\r\n\r\n  const handlePointerLeave = (e: React.PointerEvent) => {\r\n    if (e.pointerType === \"touch\") return;\r\n    if (hoverTimer.current) {\r\n      clearTimeout(hoverTimer.current);\r\n      hoverTimer.current = null;\r\n    }\r\n    if (tonable) scheduleClose();\r\n  };\r\n\r\n  const handlePointerDown = (e: React.PointerEvent) => {\r\n    if (!tonable || e.pointerType !== \"touch\") return;\r\n    longPressed.current = false;\r\n    longPressTimer.current = setTimeout(() => {\r\n      longPressed.current = true;\r\n      triggerOpen();\r\n    }, LONG_PRESS_MS);\r\n  };\r\n  const handlePointerUp = () => {\r\n    if (longPressTimer.current) clearTimeout(longPressTimer.current);\r\n    longPressTimer.current = null;\r\n  };\r\n\r\n  const handleClick = () => {\r\n    if (longPressed.current) {\r\n      longPressed.current = false;\r\n      return;\r\n    }\r\n    onPick();\r\n  };\r\n\r\n  return (\r\n    <button\r\n      ref={buttonRef}\r\n      type=\"button\"\r\n      onClick={handleClick}\r\n      onPointerEnter={handlePointerEnter}\r\n      onPointerLeave={handlePointerLeave}\r\n      onPointerDown={handlePointerDown}\r\n      onPointerUp={handlePointerUp}\r\n      onPointerCancel={handlePointerUp}\r\n      className=\"relative rounded p-1 text-xl hover:bg-zinc-100 dark:hover:bg-zinc-700\"\r\n      aria-label={emoji.name}\r\n      title={emoji.name}\r\n    >\r\n      {displayChar}\r\n      {tonable && (\r\n        <span className=\"absolute right-0.5 bottom-0.5 h-1 w-1 rounded-full bg-amber-400\" aria-hidden \/>\r\n      )}\r\n    <\/button>\r\n  );\r\n}\r\n\r\ntype PopoverProps = {\r\n  target: ToneTarget;\r\n  onPickBase: () => void;\r\n  onPickTone: (tone: SkinTone) => void;\r\n  onMouseEnter: () => void;\r\n  onMouseLeave: () => void;\r\n};\r\n\r\nfunction TonePopover({ target, onPickBase, onPickTone, onMouseEnter, onMouseLeave }: PopoverProps) {\r\n  const { emoji, rect } = target;\r\n  if (!emoji.skinTones) return null;\r\n\r\n  const centerX = rect.left + rect.width \/ 2;\r\n  const top = rect.top;\r\n  const POPOVER_HEIGHT_APPROX = 44;\r\n  const flipDown = top < POPOVER_HEIGHT_APPROX + 8;\r\n\r\n  const style: React.CSSProperties = flipDown\r\n    ? { position: \"fixed\", left: centerX, top: top + 32, transform: \"translateX(-50%)\" }\r\n    : { position: \"fixed\", left: centerX, top: top - 4, transform: \"translate(-50%, -100%)\" };\r\n\r\n  return (\r\n    <div\r\n      style={{ ...style, zIndex: 9999 }}\r\n      className=\"flex gap-0.5 rounded-md border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-800\"\r\n      onMouseEnter={onMouseEnter}\r\n      onMouseLeave={onMouseLeave}\r\n    >\r\n      <button\r\n        type=\"button\"\r\n        onClick={onPickBase}\r\n        className=\"rounded p-1 text-lg hover:bg-zinc-100 dark:hover:bg-zinc-700\"\r\n        aria-label={`${emoji.name} (no tone)`}\r\n      >\r\n        {emoji.char}\r\n      <\/button>\r\n      {SKIN_TONES.map((t, i) => (\r\n        <button\r\n          key={t}\r\n          type=\"button\"\r\n          onClick={() => onPickTone(t)}\r\n          className=\"rounded p-1 text-lg hover:bg-zinc-100 dark:hover:bg-zinc-700\"\r\n          aria-label={`${emoji.name} ${t} skin tone`}\r\n        >\r\n          {emoji.skinTones![i]}\r\n        <\/button>\r\n      ))}\r\n    <\/div>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/emoji-select\/EmojiSelect.tsx"},{"path":"components\/fancy\/emoji-select\/EmojiSelect.types.ts","content":"export interface EmojiSelectProps {\r\n  value?: string;\r\n  defaultValue?: string;\r\n  onChange?: (emoji: string) => void;\r\n  placeholder?: string;\r\n  className?: string;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/emoji-select\/EmojiSelect.types.ts"},{"path":"components\/fancy\/emoji-select\/index.ts","content":"export { EmojiSelect } from \".\/EmojiSelect\";\r\nexport type { EmojiSelectProps } from \".\/EmojiSelect.types\";\r\n","type":"registry:ui","target":"components\/fancy\/emoji-select\/index.ts"}]}