Skip to content
File

Blob: src/client/components/ui/emoji-picker-impl.tsx

typescript253 lines
1import { useCallback, useDeferredValue, useLayoutEffect, useMemo, useRef, useState } from "react";
2import type { CSSProperties } from "react";
3import "@/client/styles/emoji-picker.css";
4import { PICKER_EMOJI_ITEMS, PICKER_GROUP_ORDER, type PickerEmojiItem } from "@/client/lib/emoji/picker-data";
5import { normalizeEmoji } from "@/shared/emoji/shared";
6import { readRecentEmojis, writeRecentEmoji } from "@/client/lib/emoji/recents";
7import { useMenuNavigation } from "@/client/components/editor/controllers/menu/navigation";
8import { EmojiIcon } from "./emoji-icon";
9 
10interface EmojiPickerImplProps {
11 onSelect: (emoji: string) => void;
12 className?: string;
13 style?: CSSProperties;
14}
15 
16type ActiveGroup = "recent" | (typeof PICKER_GROUP_ORDER)[number];
17 
18const VISIBLE_GROUPS: ActiveGroup[] = ["recent", ...PICKER_GROUP_ORDER];
19 
20const 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 
33const 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 
46const 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 
51function 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 
60function 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 
71function resolveDisplayEmoji(item: PickerEmojiItem): string {
72 return item.rawEmoji ?? item.emoji ?? "";
73}
74 
75function 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 
88export 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}