index.tsx raw

   1  import { Button } from '@/components/ui/button'
   2  import { cn } from '@/lib/utils'
   3  import { parseEmojiPickerUnified } from '@/lib/utils'
   4  import { TEmoji } from '@/types'
   5  import { getSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
   6  import { MoreHorizontal } from 'lucide-react'
   7  import { useCallback, useEffect, useRef, useState } from 'react'
   8  import Emoji from '../Emoji'
   9  
  10  const DEFAULT_SUGGESTED_EMOJIS = ['👍', '❤️', '😂', '🥲', '👀', '🫡', '🫂']
  11  
  12  export default function SuggestedEmojis({
  13    onEmojiClick,
  14    onMoreButtonClick,
  15    onClose
  16  }: {
  17    onEmojiClick: (emoji: string | TEmoji) => void
  18    onMoreButtonClick: () => void
  19    onClose?: () => void
  20  }) {
  21    const [suggestedEmojis, setSuggestedEmojis] =
  22      useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
  23    const [selectedIndex, setSelectedIndex] = useState(0)
  24    const containerRef = useRef<HTMLDivElement>(null)
  25  
  26    // Total items: 1 (plus) + suggestedEmojis.length + 1 (more button)
  27    const totalItems = 1 + suggestedEmojis.length + 1
  28  
  29    useEffect(() => {
  30      try {
  31        const suggested = getSuggested()
  32        const emojiSet = new Set<string>()
  33        const suggestEmojis = (
  34          suggested
  35            .sort((a, b) => b.count - a.count)
  36            .map((item) => parseEmojiPickerUnified(item.unified))
  37            .filter(Boolean) as (string | TEmoji)[]
  38        )
  39          .concat(DEFAULT_SUGGESTED_EMOJIS)
  40          .filter((emoji) => {
  41            if (typeof emoji !== 'string') return true
  42            if (emojiSet.has(emoji)) return false
  43            emojiSet.add(emoji)
  44            return true
  45          })
  46        setSuggestedEmojis(suggestEmojis.slice(0, 9))
  47      } catch {
  48        // ignore
  49      }
  50    }, [])
  51  
  52    // Focus container on mount for keyboard events
  53    useEffect(() => {
  54      containerRef.current?.focus()
  55    }, [])
  56  
  57    const handleSelect = useCallback(() => {
  58      if (selectedIndex === 0) {
  59        // Plus button
  60        onEmojiClick('+')
  61      } else if (selectedIndex <= suggestedEmojis.length) {
  62        // Emoji
  63        onEmojiClick(suggestedEmojis[selectedIndex - 1])
  64      } else {
  65        // More button
  66        onMoreButtonClick()
  67      }
  68    }, [selectedIndex, suggestedEmojis, onEmojiClick, onMoreButtonClick])
  69  
  70    const handleKeyDown = useCallback(
  71      (e: React.KeyboardEvent) => {
  72        switch (e.key) {
  73          case 'ArrowLeft':
  74            e.preventDefault()
  75            setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1))
  76            break
  77          case 'ArrowRight':
  78            e.preventDefault()
  79            setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0))
  80            break
  81          case 'ArrowUp':
  82            e.preventDefault()
  83            // Jump to first item
  84            setSelectedIndex(0)
  85            break
  86          case 'ArrowDown':
  87            e.preventDefault()
  88            // Jump to last item (more button)
  89            setSelectedIndex(totalItems - 1)
  90            break
  91          case 'Enter':
  92          case ' ':
  93            e.preventDefault()
  94            handleSelect()
  95            break
  96          case 'Escape':
  97            e.preventDefault()
  98            onClose?.()
  99            break
 100        }
 101      },
 102      [totalItems, handleSelect, onClose]
 103    )
 104  
 105    return (
 106      <div
 107        ref={containerRef}
 108        className="flex gap-1 p-1 outline-none"
 109        onClick={(e) => e.stopPropagation()}
 110        onKeyDown={handleKeyDown}
 111        tabIndex={0}
 112      >
 113        <div
 114          className={cn(
 115            'w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl',
 116            selectedIndex === 0 && 'ring-2 ring-primary'
 117          )}
 118          onClick={() => onEmojiClick('+')}
 119        >
 120          <Emoji emoji="+" />
 121        </div>
 122        {suggestedEmojis.map((emoji, index) =>
 123          typeof emoji === 'string' ? (
 124            <div
 125              key={index}
 126              className={cn(
 127                'w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl',
 128                selectedIndex === index + 1 && 'ring-2 ring-primary'
 129              )}
 130              onClick={() => onEmojiClick(emoji)}
 131            >
 132              {emoji}
 133            </div>
 134          ) : (
 135            <div
 136              className={cn(
 137                'flex flex-col items-center justify-center p-1 rounded-lg clickable',
 138                selectedIndex === index + 1 && 'ring-2 ring-primary'
 139              )}
 140              key={index}
 141              onClick={() => onEmojiClick(emoji)}
 142            >
 143              <Emoji emoji={emoji} classNames={{ img: 'size-6 rounded-md' }} />
 144            </div>
 145          )
 146        )}
 147        <Button
 148          variant="ghost"
 149          className={cn(
 150            'w-8 h-8 text-muted-foreground',
 151            selectedIndex === totalItems - 1 && 'ring-2 ring-primary'
 152          )}
 153          onClick={onMoreButtonClick}
 154        >
 155          <MoreHorizontal size={24} />
 156        </Button>
 157      </div>
 158    )
 159  }
 160