File
Blob: src/client/components/ui/emoji-picker-impl.tsx
| 1 | import { useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 2 | import type { CSSProperties } from "react"; |
| 3 | import "@/client/styles/emoji-picker.css"; |
| 4 | import { PICKER_EMOJI_ITEMS, PICKER_GROUP_ORDER, type PickerEmojiItem } from "@/client/lib/emoji/picker-data"; |
| 5 | import { normalizeEmoji } from "@/shared/emoji/shared"; |
| 6 | import { readRecentEmojis, writeRecentEmoji } from "@/client/lib/emoji/recents"; |
| 7 | import { useMenuNavigation } from "@/client/components/editor/controllers/menu/navigation"; |
| 8 | import { EmojiIcon } from "./emoji-icon"; |
| 9 | |
| 10 | interface EmojiPickerImplProps { |
| 11 | onSelect: (emoji: string) => void; |
| 12 | className?: string; |
| 13 | style?: CSSProperties; |
| 14 | } |
| 15 | |
| 16 | type ActiveGroup = "recent" | (typeof PICKER_GROUP_ORDER)[number]; |
| 17 | |
| 18 | const VISIBLE_GROUPS: ActiveGroup[] = ["recent", ...PICKER_GROUP_ORDER]; |
| 19 | |
| 20 | const GROUP_LABELS: Record<ActiveGroup, string> = { |
| 21 | recent: "Recent", |
| 22 | "smileys & emotion": "Smileys", |
| 23 | "people & body": "People", |
| 24 | "animals & nature": "Nature", |
| 25 | "food & drink": "Food", |
| 26 | "travel & places": "Travel", |
| 27 | activities: "Activity", |
| 28 | objects: "Objects", |
| 29 | symbols: "Symbols", |
| 30 | flags: "Flags", |
| 31 | }; |
| 32 | |
| 33 | const GROUP_GLYPHS: Record<ActiveGroup, string> = { |
| 34 | recent: "๐", |
| 35 | "smileys & emotion": "๐", |
| 36 | "people & body": "๐", |
| 37 | "animals & nature": "๐ถ", |
| 38 | "food & drink": "๐", |
| 39 | "travel & places": "โ๏ธ", |
| 40 | activities: "โฝ", |
| 41 | objects: "๐ก", |
| 42 | symbols: "๐ฃ", |
| 43 | flags: "๐", |
| 44 | }; |
| 45 | |
| 46 | const PICKER_EMOJIS_BY_GROUP = PICKER_GROUP_ORDER.reduce<Record<string, PickerEmojiItem[]>>((groups, group) => { |
| 47 | groups[group] = PICKER_EMOJI_ITEMS.filter((item) => item.group === group); |
| 48 | return groups; |
| 49 | }, {}); |
| 50 | |
| 51 | function getEmojiSearchTerms(item: PickerEmojiItem): string[] { |
| 52 | const expandedTerms = [item.name, ...item.shortcodes, ...item.tags].filter(Boolean).flatMap((term) => { |
| 53 | const normalizedTerm = term.toLowerCase(); |
| 54 | const spacedTerm = normalizedTerm.replace(/[_+-]+/g, " "); |
| 55 | return normalizedTerm === spacedTerm ? [normalizedTerm] : [normalizedTerm, spacedTerm]; |
| 56 | }); |
| 57 | return expandedTerms; |
| 58 | } |
| 59 | |
| 60 | function recentsToItems(recents: string[]): PickerEmojiItem[] { |
| 61 | return recents.map((raw, index) => ({ |
| 62 | emoji: normalizeEmoji(raw), |
| 63 | rawEmoji: raw, |
| 64 | name: `recent-${index}-${raw}`, |
| 65 | shortcodes: [], |
| 66 | tags: [], |
| 67 | group: "recent", |
| 68 | })); |
| 69 | } |
| 70 | |
| 71 | function resolveDisplayEmoji(item: PickerEmojiItem): string { |
| 72 | return item.rawEmoji ?? item.emoji ?? ""; |
| 73 | } |
| 74 | |
| 75 | function measureColumnCount(grid: HTMLElement | null): number { |
| 76 | if (!grid) return 1; |
| 77 | const items = grid.querySelectorAll<HTMLElement>("[data-menu-index]"); |
| 78 | if (items.length <= 1) return Math.max(1, items.length); |
| 79 | const firstTop = items[0].offsetTop; |
| 80 | let count = 0; |
| 81 | for (const item of items) { |
| 82 | if (item.offsetTop !== firstTop) break; |
| 83 | count++; |
| 84 | } |
| 85 | return Math.max(1, count); |
| 86 | } |
| 87 | |
| 88 | export function EmojiPickerImpl({ onSelect, className, style }: EmojiPickerImplProps) { |
| 89 | const [query, setQuery] = useState(""); |
| 90 | const [recents, setRecents] = useState<string[]>(() => readRecentEmojis()); |
| 91 | const [activeGroup, setActiveGroup] = useState<ActiveGroup>(() => |
| 92 | readRecentEmojis().length > 0 ? "recent" : "smileys & emotion", |
| 93 | ); |
| 94 | const [columns, setColumns] = useState(1); |
| 95 | |
| 96 | const deferredQuery = useDeferredValue(query); |
| 97 | const normalizedQuery = deferredQuery.trim().toLowerCase(); |
| 98 | const isSearching = normalizedQuery !== ""; |
| 99 | |
| 100 | const gridRef = useRef<HTMLDivElement>(null); |
| 101 | |
| 102 | const visibleEmojis: PickerEmojiItem[] = useMemo(() => { |
| 103 | if (isSearching) { |
| 104 | return PICKER_EMOJI_ITEMS.filter((item) => |
| 105 | getEmojiSearchTerms(item).some((term) => term.includes(normalizedQuery)), |
| 106 | ); |
| 107 | } |
| 108 | if (activeGroup === "recent") { |
| 109 | return recentsToItems(recents); |
| 110 | } |
| 111 | return PICKER_EMOJIS_BY_GROUP[activeGroup] ?? []; |
| 112 | }, [activeGroup, isSearching, normalizedQuery, recents]); |
| 113 | |
| 114 | const sectionLabel = isSearching |
| 115 | ? `${visibleEmojis.length} result${visibleEmojis.length === 1 ? "" : "s"}` |
| 116 | : GROUP_LABELS[activeGroup]; |
| 117 | |
| 118 | const handleSelect = useCallback( |
| 119 | (item: PickerEmojiItem) => { |
| 120 | const emoji = resolveDisplayEmoji(item); |
| 121 | if (!emoji) return; |
| 122 | setRecents(writeRecentEmoji(emoji)); |
| 123 | onSelect(emoji); |
| 124 | }, |
| 125 | [onSelect], |
| 126 | ); |
| 127 | |
| 128 | const gridNav = useMenuNavigation<PickerEmojiItem>({ |
| 129 | items: visibleEmojis, |
| 130 | listRef: gridRef, |
| 131 | onSelect: handleSelect, |
| 132 | columns, |
| 133 | }); |
| 134 | |
| 135 | useLayoutEffect(() => { |
| 136 | if (!gridRef.current) return; |
| 137 | const grid = gridRef.current; |
| 138 | let rafId: number | null = null; |
| 139 | const update = () => setColumns(measureColumnCount(grid)); |
| 140 | const schedule = () => { |
| 141 | if (rafId !== null) return; |
| 142 | rafId = requestAnimationFrame(() => { |
| 143 | rafId = null; |
| 144 | update(); |
| 145 | }); |
| 146 | }; |
| 147 | update(); |
| 148 | const observer = new ResizeObserver(schedule); |
| 149 | observer.observe(grid); |
| 150 | return () => { |
| 151 | if (rafId !== null) cancelAnimationFrame(rafId); |
| 152 | observer.disconnect(); |
| 153 | }; |
| 154 | }, [visibleEmojis]); |
| 155 | |
| 156 | const handleSearchKeyDown = useCallback( |
| 157 | (event: React.KeyboardEvent<HTMLInputElement>) => { |
| 158 | if (event.key === "ArrowLeft" || event.key === "ArrowRight") { |
| 159 | const input = event.currentTarget; |
| 160 | const caret = input.selectionStart; |
| 161 | const collapsed = caret !== null && caret === input.selectionEnd; |
| 162 | const atBoundary = collapsed && (event.key === "ArrowLeft" ? caret === 0 : caret === input.value.length); |
| 163 | if (!atBoundary) return; |
| 164 | } else if (!["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | if (gridNav.onKeyDown(event.nativeEvent)) { |
| 169 | event.preventDefault(); |
| 170 | } |
| 171 | }, |
| 172 | [gridNav], |
| 173 | ); |
| 174 | |
| 175 | return ( |
| 176 | <div className={["bland-emoji-picker", className].filter(Boolean).join(" ")} style={style}> |
| 177 | <div className="bland-emoji-picker-search-row"> |
| 178 | <input |
| 179 | type="search" |
| 180 | className="bland-emoji-picker-search" |
| 181 | placeholder="Search emoji..." |
| 182 | value={query} |
| 183 | onChange={(event) => setQuery(event.target.value)} |
| 184 | onKeyDown={handleSearchKeyDown} |
| 185 | autoFocus |
| 186 | /> |
| 187 | </div> |
| 188 | |
| 189 | <div className="bland-emoji-picker-body"> |
| 190 | <div className="bland-emoji-picker-section-label">{sectionLabel}</div> |
| 191 | {visibleEmojis.length === 0 ? ( |
| 192 | <div className="bland-emoji-picker-empty"> |
| 193 | {isSearching ? "Nothing matches. Try fewer letters." : "No emoji here yet."} |
| 194 | </div> |
| 195 | ) : ( |
| 196 | <div |
| 197 | ref={gridRef} |
| 198 | className="bland-emoji-picker-grid" |
| 199 | role="tabpanel" |
| 200 | aria-label={isSearching ? "Emoji search results" : GROUP_LABELS[activeGroup]} |
| 201 | > |
| 202 | {visibleEmojis.map((item, index) => { |
| 203 | const nextEmoji = resolveDisplayEmoji(item); |
| 204 | const shortcodeTitle = item.shortcodes[0] ? `:${item.shortcodes[0]}:` : item.name; |
| 205 | return ( |
| 206 | <button |
| 207 | key={item.name} |
| 208 | type="button" |
| 209 | data-menu-index={index} |
| 210 | className="bland-emoji-picker-item" |
| 211 | title={shortcodeTitle} |
| 212 | aria-selected={gridNav.selectedIndex === index} |
| 213 | onMouseEnter={() => gridNav.setSelectedIndex(index)} |
| 214 | onClick={() => handleSelect(item)} |
| 215 | > |
| 216 | <EmojiIcon emoji={nextEmoji} size={20} /> |
| 217 | </button> |
| 218 | ); |
| 219 | })} |
| 220 | </div> |
| 221 | )} |
| 222 | </div> |
| 223 | |
| 224 | <div className="bland-emoji-picker-groups" role="tablist" aria-label="Emoji categories"> |
| 225 | {VISIBLE_GROUPS.map((group) => { |
| 226 | const tabId = `bland-emoji-tab-${group.replace(/\s|&/g, "-")}`; |
| 227 | const isActive = !isSearching && group === activeGroup; |
| 228 | const isDisabled = group === "recent" && recents.length === 0; |
| 229 | return ( |
| 230 | <button |
| 231 | key={group} |
| 232 | id={tabId} |
| 233 | type="button" |
| 234 | className={`bland-emoji-picker-group${isActive ? " is-active" : ""}`} |
| 235 | onClick={() => { |
| 236 | setActiveGroup(group); |
| 237 | setQuery(""); |
| 238 | }} |
| 239 | disabled={isDisabled} |
| 240 | role="tab" |
| 241 | aria-selected={isActive} |
| 242 | aria-label={GROUP_LABELS[group]} |
| 243 | title={GROUP_LABELS[group]} |
| 244 | > |
| 245 | <EmojiIcon emoji={GROUP_GLYPHS[group]} size={18} /> |
| 246 | </button> |
| 247 | ); |
| 248 | })} |
| 249 | </div> |
| 250 | </div> |
| 251 | ); |
| 252 | } |