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