import { useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import "@/client/styles/emoji-picker.css"; import { PICKER_EMOJI_ITEMS, PICKER_GROUP_ORDER, type PickerEmojiItem } from "@/client/lib/emoji/picker-data"; import { normalizeEmoji } from "@/shared/emoji/shared"; import { readRecentEmojis, writeRecentEmoji } from "@/client/lib/emoji/recents"; import { useMenuNavigation } from "@/client/components/editor/controllers/menu/navigation"; import { EmojiIcon } from "./emoji-icon"; interface EmojiPickerImplProps { onSelect: (emoji: string) => void; className?: string; style?: CSSProperties; } type ActiveGroup = "recent" | (typeof PICKER_GROUP_ORDER)[number]; const VISIBLE_GROUPS: ActiveGroup[] = ["recent", ...PICKER_GROUP_ORDER]; const GROUP_LABELS: Record = { recent: "Recent", "smileys & emotion": "Smileys", "people & body": "People", "animals & nature": "Nature", "food & drink": "Food", "travel & places": "Travel", activities: "Activity", objects: "Objects", symbols: "Symbols", flags: "Flags", }; const GROUP_GLYPHS: Record = { recent: "🕐", "smileys & emotion": "😀", "people & body": "👋", "animals & nature": "🐶", "food & drink": "🍔", "travel & places": "✈️", activities: "⚽", objects: "💡", symbols: "🔣", flags: "🏁", }; const PICKER_EMOJIS_BY_GROUP = PICKER_GROUP_ORDER.reduce>((groups, group) => { groups[group] = PICKER_EMOJI_ITEMS.filter((item) => item.group === group); return groups; }, {}); function getEmojiSearchTerms(item: PickerEmojiItem): string[] { const expandedTerms = [item.name, ...item.shortcodes, ...item.tags].filter(Boolean).flatMap((term) => { const normalizedTerm = term.toLowerCase(); const spacedTerm = normalizedTerm.replace(/[_+-]+/g, " "); return normalizedTerm === spacedTerm ? [normalizedTerm] : [normalizedTerm, spacedTerm]; }); return expandedTerms; } function recentsToItems(recents: string[]): PickerEmojiItem[] { return recents.map((raw, index) => ({ emoji: normalizeEmoji(raw), rawEmoji: raw, name: `recent-${index}-${raw}`, shortcodes: [], tags: [], group: "recent", })); } function resolveDisplayEmoji(item: PickerEmojiItem): string { return item.rawEmoji ?? item.emoji ?? ""; } function measureColumnCount(grid: HTMLElement | null): number { if (!grid) return 1; const items = grid.querySelectorAll("[data-menu-index]"); if (items.length <= 1) return Math.max(1, items.length); const firstTop = items[0].offsetTop; let count = 0; for (const item of items) { if (item.offsetTop !== firstTop) break; count++; } return Math.max(1, count); } export function EmojiPickerImpl({ onSelect, className, style }: EmojiPickerImplProps) { const [query, setQuery] = useState(""); const [recents, setRecents] = useState(() => readRecentEmojis()); const [activeGroup, setActiveGroup] = useState(() => readRecentEmojis().length > 0 ? "recent" : "smileys & emotion", ); const [columns, setColumns] = useState(1); const deferredQuery = useDeferredValue(query); const normalizedQuery = deferredQuery.trim().toLowerCase(); const isSearching = normalizedQuery !== ""; const gridRef = useRef(null); const visibleEmojis: PickerEmojiItem[] = useMemo(() => { if (isSearching) { return PICKER_EMOJI_ITEMS.filter((item) => getEmojiSearchTerms(item).some((term) => term.includes(normalizedQuery)), ); } if (activeGroup === "recent") { return recentsToItems(recents); } return PICKER_EMOJIS_BY_GROUP[activeGroup] ?? []; }, [activeGroup, isSearching, normalizedQuery, recents]); const sectionLabel = isSearching ? `${visibleEmojis.length} result${visibleEmojis.length === 1 ? "" : "s"}` : GROUP_LABELS[activeGroup]; const handleSelect = useCallback( (item: PickerEmojiItem) => { const emoji = resolveDisplayEmoji(item); if (!emoji) return; setRecents(writeRecentEmoji(emoji)); onSelect(emoji); }, [onSelect], ); const gridNav = useMenuNavigation({ items: visibleEmojis, listRef: gridRef, onSelect: handleSelect, columns, }); useLayoutEffect(() => { if (!gridRef.current) return; const grid = gridRef.current; let rafId: number | null = null; const update = () => setColumns(measureColumnCount(grid)); const schedule = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; update(); }); }; update(); const observer = new ResizeObserver(schedule); observer.observe(grid); return () => { if (rafId !== null) cancelAnimationFrame(rafId); observer.disconnect(); }; }, [visibleEmojis]); const handleSearchKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { const input = event.currentTarget; const caret = input.selectionStart; const collapsed = caret !== null && caret === input.selectionEnd; const atBoundary = collapsed && (event.key === "ArrowLeft" ? caret === 0 : caret === input.value.length); if (!atBoundary) return; } else if (!["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { return; } if (gridNav.onKeyDown(event.nativeEvent)) { event.preventDefault(); } }, [gridNav], ); return (
setQuery(event.target.value)} onKeyDown={handleSearchKeyDown} autoFocus />
{sectionLabel}
{visibleEmojis.length === 0 ? (
{isSearching ? "Nothing matches. Try fewer letters." : "No emoji here yet."}
) : (
{visibleEmojis.map((item, index) => { const nextEmoji = resolveDisplayEmoji(item); const shortcodeTitle = item.shortcodes[0] ? `:${item.shortcodes[0]}:` : item.name; return ( ); })}
)}
{VISIBLE_GROUPS.map((group) => { const tabId = `bland-emoji-tab-${group.replace(/\s|&/g, "-")}`; const isActive = !isSearching && group === activeGroup; const isDisabled = group === "recent" && recents.length === 0; return ( ); })}
); }