Skip to content
281 linesCodeBlameRaw
1import { Clock, Plus, Search, Sparkles } from "lucide-react";
2import { type KeyboardEvent, type ReactElement, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
3import { Link } from "react-router";
4
5import { useEmojiContext } from "./context";
6import { readRecent, readTone, rememberRecent, rememberTone, useEmojiData } from "./data";
7import { EmojiGlyph } from "./render";
8import { Button } from "../ui/button";
9import { Hint } from "../ui/hint";
10import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
11import { type EmojiPick, SKIN_TONES, customPick, searchEmoji, skinned, unicodePick } from "../../lib/emoji";
12
13/** Emoji per row. */
14const COLUMNS = 9;
15/** One for each group in data.json, in its order. */
16const GROUP_ICONS = ["😀", "👋", "🐻", "🍔", "✈️", "⚽", "💡", "🔣", "🏁"];
17
18type Section = { key: string; title: string; icon: ReactNode; picks: EmojiPick[] };
19
20/**
21 * Choosing an emoji: type to search by name, shortcode or keyword, or
22 * browse by group; recently used and the workspace's own first. Arrow keys
23 * move through the grid while typing, Enter picks, Escape closes (the
24 * popover around it). The skin tone chosen is remembered in this browser.
25 */
26export function EmojiPicker({ onPick, autoFocus = true }: { onPick: (emoji: string) => void; autoFocus?: boolean }) {
27 const { customs, byName, usercontent, manageHref } = useEmojiContext();
28 const data = useEmojiData();
29 const [query, setQuery] = useState("");
30 const [tone, setTone] = useState(0);
31 const [toning, setToning] = useState(false);
32 const [recent, setRecent] = useState<string[]>([]);
33 const [active, setActive] = useState(0);
34 const grid = useRef<HTMLDivElement>(null);
35 const list = useId();
36
37 useEffect(() => {
38 setTone(readTone());
39 setRecent(readRecent());
40 }, []);
41
42 const sections = useMemo<Section[]>(() => {
43 if (query.trim()) {
44 return [{ key: "results", title: "Results", icon: <Search size={14} />, picks: searchEmoji(data, customs, query, 90) }];
45 }
46 const out: Section[] = [];
47 const recentPicks = recent.flatMap((emoji): EmojiPick[] => {
48 const custom = /^:([a-z0-9_+-]{2,32}):$/.exec(emoji);
49 if (custom) {
50 const file = byName.get(custom[1]!);
51 return file ? [customPick({ name: custom[1]!, file })] : [];
52 }
53 return [{ kind: "unicode", emoji, name: data?.emoji.find((e) => e[0] === emoji || e[5]?.includes(emoji))?.[1] ?? emoji, code: null }];
54 });
55 if (recentPicks.length) out.push({ key: "recent", title: "Recently used", icon: <Clock size={14} />, picks: recentPicks });
56 if (customs.length) out.push({ key: "custom", title: "This workspace", icon: <Sparkles size={14} />, picks: customs.map(customPick) });
57 data?.groups.forEach((title, group) => {
58 out.push({ key: `g${group}`, title, icon: GROUP_ICONS[group] ?? "", picks: data.emoji.filter((e) => e[4] === group).map(unicodePick) });
59 });
60 return out;
61 }, [query, data, customs, recent, byName]);
62
63 const flat = useMemo(() => sections.flatMap((s) => s.picks), [sections]);
64 useEffect(() => setActive(0), [query]);
65 const current = flat[Math.min(active, flat.length - 1)] ?? null;
66
67 // The active emoji stays in view as the arrows move it.
68 useEffect(() => {
69 grid.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
70 }, [active]);
71
72 const pick = (choice: EmojiPick) => {
73 const emoji = choice.kind === "unicode" ? skinned(choice, tone) : choice.emoji;
74 setRecent(rememberRecent(emoji));
75 onPick(emoji);
76 };
77
78 const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
79 const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: COLUMNS, ArrowUp: -COLUMNS }[event.key];
80 if (move != null && flat.length) {
81 // Left and right belong to the search box while it has text to move through.
82 if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && query && event.target instanceof HTMLInputElement) return;
83 event.preventDefault();
84 setActive((index) => Math.max(0, Math.min(flat.length - 1, index + move)));
85 } else if (event.key === "Enter" && current) {
86 event.preventDefault();
87 pick(current);
88 } else if (event.key === "Home" && !(event.target instanceof HTMLInputElement)) {
89 event.preventDefault();
90 setActive(0);
91 } else if (event.key === "End" && !(event.target instanceof HTMLInputElement)) {
92 event.preventDefault();
93 setActive(flat.length - 1);
94 }
95 };
96
97 const jump = (key: string) => {
98 const at = grid.current?.querySelector<HTMLElement>(`[data-section="${key}"]`);
99 if (at && grid.current) grid.current.scrollTop = at.offsetTop - grid.current.offsetTop;
100 };
101
102 let index = 0;
103 return (
104 <div className="flex h-[26rem] w-[22.5rem] max-w-[calc(100vw-1rem)] flex-col" onKeyDown={onKeyDown}>
105 <div className="flex items-center gap-2 border-b border-line p-2">
106 <label className="flex h-8 min-w-0 grow items-center gap-2 rounded-md border border-line-strong bg-bg px-2 focus-within:border-accent/50">
107 <Search size={14} className="shrink-0 text-faint" />
108 <input
109 autoFocus={autoFocus}
110 value={query}
111 onChange={(event) => setQuery(event.target.value)}
112 placeholder="Search emoji"
113 aria-label="Search emoji"
114 role="combobox"
115 aria-expanded
116 aria-controls={list}
117 aria-activedescendant={current ? `${list}-${Math.min(active, flat.length - 1)}` : undefined}
118 autoComplete="off"
119 data-1p-ignore
120 className="min-w-0 grow bg-transparent text-sm text-fg outline-none placeholder:text-faint"
121 />
122 </label>
123 <div className="relative">
124 <Hint label="Skin tone">
125 <Button
126 type="button"
127 aria-label="Skin tone"
128 aria-expanded={toning}
129 onClick={() => setToning((open) => !open)}
130 variant="ghost"
131 size="icon-sm"
132 className="text-lg"
133 >
134 {SKIN_TONES[tone]}
135 </Button>
136 </Hint>
137 {toning && (
138 <div role="radiogroup" aria-label="Skin tone" className="absolute top-full right-0 z-10 mt-1 flex gap-0.5 rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40">
139 {SKIN_TONES.map((hand, choice) => (
140 <button
141 key={hand}
142 type="button"
143 role="radio"
144 aria-checked={choice === tone}
145 aria-label={choice === 0 ? "No skin tone" : `Skin tone ${choice}`}
146 onClick={() => {
147 setTone(choice);
148 rememberTone(choice);
149 setToning(false);
150 }}
151 className={`flex size-8 items-center justify-center rounded-md text-lg hover:bg-line ${choice === tone ? "bg-line" : ""}`}
152 >
153 {hand}
154 </button>
155 ))}
156 </div>
157 )}
158 </div>
159 </div>
160 {!query.trim() && (
161 <nav aria-label="Emoji groups" className="flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-line px-1.5 py-1 [scrollbar-width:none]">
162 {sections.map((section) => (
163 <Hint key={section.key} label={section.title}>
164 <Button
165 type="button"
166 aria-label={section.title}
167 onClick={() => jump(section.key)}
168 variant="ghost"
169 size="icon-xs"
170 className="text-[0.95rem] text-faint grayscale-[0.6] transition hover:grayscale-0"
171 >
172 {section.icon}
173 </Button>
174 </Hint>
175 ))}
176 </nav>
177 )}
178 <div ref={grid} id={list} role="listbox" aria-label="Emoji" className="relative min-h-0 grow overflow-y-auto px-2 pb-2 [scrollbar-width:thin]">
179 {!data && !query.trim() && <p className="px-1 py-6 text-center text-sm text-faint">Loading emoji…</p>}
180 {sections.map((section) => (
181 <section key={section.key} data-section={section.key} aria-label={section.title}>
182 <h3 className="sticky top-0 z-[1] flex items-center justify-between bg-raised/95 px-1 pt-2 pb-1 text-[0.6875rem] font-semibold tracking-wide text-faint uppercase backdrop-blur">
183 {section.title}
184 {section.key === "custom" && manageHref && (
185 <Link to={manageHref} className="font-medium tracking-normal normal-case text-accent hover:underline">
186 Manage
187 </Link>
188 )}
189 </h3>
190 {section.picks.length === 0 ? (
191 <p className="px-1 py-6 text-center text-sm text-faint">No emoji match “{query.trim()}”.</p>
192 ) : (
193 <div className="grid grid-cols-9">
194 {section.picks.map((choice) => {
195 const at = index++;
196 const glyph = choice.kind === "unicode" ? skinned(choice, tone) : choice.emoji;
197 return (
198 <button
199 key={`${section.key}:${choice.emoji}`}
200 type="button"
201 tabIndex={-1}
202 id={`${list}-${at}`}
203 data-index={at}
204 role="option"
205 aria-selected={at === active}
206 aria-label={choice.kind === "custom" ? `:${choice.name}:` : choice.name}
207 onMouseEnter={() => setActive(at)}
208 onClick={() => pick(choice)}
209 className={`flex aspect-square items-center justify-center rounded-md text-[1.375rem] leading-none transition-colors ${
210 at === active ? "bg-line" : "hover:bg-line"
211 }`}
212 >
213 <EmojiGlyph emoji={glyph} byName={byName} usercontent={usercontent} size={choice.kind === "custom" ? 24 : 22} />
214 </button>
215 );
216 })}
217 </div>
218 )}
219 </section>
220 ))}
221 {!query.trim() && !customs.length && manageHref && (
222 <Link to={manageHref} className="mt-2 flex items-center gap-2 rounded-md px-2 py-2 text-[0.8125rem] text-muted hover:bg-line hover:text-fg">
223 <Plus size={14} /> Add this workspace’s own emoji
224 </Link>
225 )}
226 </div>
227 <div className="flex h-12 shrink-0 items-center gap-2.5 border-t border-line px-3">
228 {current ? (
229 <>
230 <EmojiGlyph emoji={current.kind === "unicode" ? skinned(current, tone) : current.emoji} byName={byName} usercontent={usercontent} size={26} />
231 <span className="min-w-0 truncate text-sm">
232 <span className="text-fg-soft first-letter:uppercase">{current.name}</span>
233 {(current.kind === "custom" || current.code) && (
234 <span className="ml-1.5 font-mono text-xs text-faint">:{current.kind === "custom" ? current.name : current.code}:</span>
235 )}
236 </span>
237 </>
238 ) : (
239 <span className="text-sm text-faint">Pick an emoji</span>
240 )}
241 </div>
242 </div>
243 );
244}
245
246/** The picker in a popover, opened by `children` (one element). Picking closes it. */
247export function EmojiPickerPopover({
248 children,
249 onPick,
250 side = "top",
251 align = "end",
252 open: controlled,
253 onOpenChange,
254}: {
255 children: ReactElement;
256 onPick: (emoji: string) => void;
257 side?: "top" | "bottom" | "left" | "right";
258 align?: "start" | "center" | "end";
259 open?: boolean;
260 onOpenChange?: (open: boolean) => void;
261}) {
262 const [own, setOwn] = useState(false);
263 const open = controlled ?? own;
264 const setOpen = (next: boolean) => {
265 setOwn(next);
266 onOpenChange?.(next);
267 };
268 return (
269 <Popover open={open} onOpenChange={setOpen}>
270 <PopoverTrigger asChild>{children}</PopoverTrigger>
271 <PopoverContent side={side} align={align} collisionPadding={8} className="w-auto overflow-hidden p-0">
272 <EmojiPicker
273 onPick={(emoji) => {
274 onPick(emoji);
275 setOpen(false);
276 }}
277 />
278 </PopoverContent>
279 </Popover>
280 );
281}