@a4ui/core 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ // Compact, dependency-free emoji picker: a small curated dataset lives inline
2
+ // (no network fetch, no emoji-data package) so it works offline and stays
3
+ // tree-shakeable. Search filters by keyword; category tabs scroll the grid
4
+ // (single scroll container, not a tab panel switch) so browsing feels
5
+ // continuous. Recents are session-only (a plain signal, no persistence) since
6
+ // "last used in this session" is the only guarantee callers should rely on.
7
+ import { Dumbbell, Hash, Lightbulb, Leaf, Plane, Search, Smile, Users, Utensils } from 'lucide-solid'
8
+ import { createMemo, createSignal, For, Show, type Component, type JSX } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ type Category = 'Smileys' | 'People' | 'Nature' | 'Food' | 'Activity' | 'Travel' | 'Objects' | 'Symbols'
13
+
14
+ interface EmojiEntry {
15
+ emoji: string
16
+ keywords: string[]
17
+ category: Category
18
+ }
19
+
20
+ const CATEGORY_ICON: Record<Category, Component<{ class?: string }>> = {
21
+ Smileys: Smile,
22
+ People: Users,
23
+ Nature: Leaf,
24
+ Food: Utensils,
25
+ Activity: Dumbbell,
26
+ Travel: Plane,
27
+ Objects: Lightbulb,
28
+ Symbols: Hash,
29
+ }
30
+
31
+ const CATEGORIES: Category[] = [
32
+ 'Smileys',
33
+ 'People',
34
+ 'Nature',
35
+ 'Food',
36
+ 'Activity',
37
+ 'Travel',
38
+ 'Objects',
39
+ 'Symbols',
40
+ ]
41
+
42
+ // ~150 common emoji, curated across 8 categories. Keywords are lowercase and
43
+ // cover the obvious English search terms (name, synonyms, related feelings).
44
+ const EMOJI_DATA: EmojiEntry[] = [
45
+ // Smileys
46
+ { emoji: '😀', keywords: ['grin', 'happy', 'smile'], category: 'Smileys' },
47
+ { emoji: '😃', keywords: ['happy', 'joy', 'smile'], category: 'Smileys' },
48
+ { emoji: '😄', keywords: ['happy', 'laugh', 'smile'], category: 'Smileys' },
49
+ { emoji: '😁', keywords: ['grin', 'happy', 'teeth'], category: 'Smileys' },
50
+ { emoji: '😆', keywords: ['laugh', 'lol', 'squint'], category: 'Smileys' },
51
+ { emoji: '😅', keywords: ['sweat', 'laugh', 'relief'], category: 'Smileys' },
52
+ { emoji: '🤣', keywords: ['rofl', 'laugh', 'funny'], category: 'Smileys' },
53
+ { emoji: '😂', keywords: ['tears', 'laugh', 'joy', 'lol'], category: 'Smileys' },
54
+ { emoji: '🙂', keywords: ['smile', 'slight', 'ok'], category: 'Smileys' },
55
+ { emoji: '🙃', keywords: ['upside down', 'silly'], category: 'Smileys' },
56
+ { emoji: '😉', keywords: ['wink', 'flirt'], category: 'Smileys' },
57
+ { emoji: '😊', keywords: ['blush', 'smile', 'happy'], category: 'Smileys' },
58
+ { emoji: '😍', keywords: ['love', 'heart eyes', 'crush'], category: 'Smileys' },
59
+ { emoji: '😘', keywords: ['kiss', 'love'], category: 'Smileys' },
60
+ { emoji: '😋', keywords: ['yum', 'tasty', 'tongue'], category: 'Smileys' },
61
+ { emoji: '😎', keywords: ['cool', 'sunglasses'], category: 'Smileys' },
62
+ { emoji: '🤩', keywords: ['star struck', 'excited', 'wow'], category: 'Smileys' },
63
+ { emoji: '🥳', keywords: ['party', 'celebrate', 'birthday'], category: 'Smileys' },
64
+ { emoji: '😐', keywords: ['neutral', 'meh'], category: 'Smileys' },
65
+ { emoji: '🙄', keywords: ['eyeroll', 'annoyed'], category: 'Smileys' },
66
+ { emoji: '😴', keywords: ['sleep', 'tired', 'zzz'], category: 'Smileys' },
67
+ { emoji: '🤔', keywords: ['think', 'hmm', 'ponder'], category: 'Smileys' },
68
+ { emoji: '😢', keywords: ['sad', 'cry', 'tear'], category: 'Smileys' },
69
+ { emoji: '😭', keywords: ['sob', 'cry', 'sad', 'bawling'], category: 'Smileys' },
70
+ { emoji: '😡', keywords: ['angry', 'mad', 'rage'], category: 'Smileys' },
71
+ { emoji: '🤯', keywords: ['mind blown', 'shocked'], category: 'Smileys' },
72
+ { emoji: '😱', keywords: ['scream', 'shocked', 'scared'], category: 'Smileys' },
73
+ { emoji: '🥺', keywords: ['pleading', 'puppy eyes', 'please'], category: 'Smileys' },
74
+ { emoji: '😇', keywords: ['angel', 'halo', 'innocent'], category: 'Smileys' },
75
+ { emoji: '🤗', keywords: ['hug', 'embrace'], category: 'Smileys' },
76
+
77
+ // People
78
+ { emoji: '👋', keywords: ['wave', 'hello', 'bye'], category: 'People' },
79
+ { emoji: '👍', keywords: ['thumbs up', 'like', 'yes', 'ok'], category: 'People' },
80
+ { emoji: '👎', keywords: ['thumbs down', 'dislike', 'no'], category: 'People' },
81
+ { emoji: '👏', keywords: ['clap', 'applause', 'bravo'], category: 'People' },
82
+ { emoji: '🙌', keywords: ['raise hands', 'celebrate', 'praise'], category: 'People' },
83
+ { emoji: '🙏', keywords: ['pray', 'please', 'thanks'], category: 'People' },
84
+ { emoji: '💪', keywords: ['muscle', 'strong', 'flex'], category: 'People' },
85
+ { emoji: '🤝', keywords: ['handshake', 'deal', 'agreement'], category: 'People' },
86
+ { emoji: '✌️', keywords: ['peace', 'victory'], category: 'People' },
87
+ { emoji: '🤞', keywords: ['fingers crossed', 'luck', 'hope'], category: 'People' },
88
+ { emoji: '👌', keywords: ['ok', 'perfect'], category: 'People' },
89
+ { emoji: '👀', keywords: ['eyes', 'look', 'watching'], category: 'People' },
90
+ { emoji: '🧠', keywords: ['brain', 'smart', 'mind'], category: 'People' },
91
+ { emoji: '👶', keywords: ['baby', 'infant'], category: 'People' },
92
+ { emoji: '🧑', keywords: ['person', 'adult'], category: 'People' },
93
+ { emoji: '👨', keywords: ['man'], category: 'People' },
94
+ { emoji: '👩', keywords: ['woman'], category: 'People' },
95
+ { emoji: '🧓', keywords: ['older person', 'elder'], category: 'People' },
96
+ { emoji: '👴', keywords: ['old man', 'grandpa'], category: 'People' },
97
+ { emoji: '👵', keywords: ['old woman', 'grandma'], category: 'People' },
98
+ { emoji: '👮', keywords: ['police', 'officer', 'cop'], category: 'People' },
99
+ { emoji: '🧑‍💻', keywords: ['coder', 'developer', 'programmer'], category: 'People' },
100
+ { emoji: '🧑‍🎓', keywords: ['student', 'graduate'], category: 'People' },
101
+ { emoji: '🧑‍🍳', keywords: ['chef', 'cook'], category: 'People' },
102
+ { emoji: '👨‍👩‍👧', keywords: ['family'], category: 'People' },
103
+
104
+ // Nature
105
+ { emoji: '🐶', keywords: ['dog', 'puppy'], category: 'Nature' },
106
+ { emoji: '🐱', keywords: ['cat', 'kitten'], category: 'Nature' },
107
+ { emoji: '🐭', keywords: ['mouse'], category: 'Nature' },
108
+ { emoji: '🐰', keywords: ['rabbit', 'bunny'], category: 'Nature' },
109
+ { emoji: '🦊', keywords: ['fox'], category: 'Nature' },
110
+ { emoji: '🐻', keywords: ['bear'], category: 'Nature' },
111
+ { emoji: '🐼', keywords: ['panda'], category: 'Nature' },
112
+ { emoji: '🦁', keywords: ['lion'], category: 'Nature' },
113
+ { emoji: '🐸', keywords: ['frog'], category: 'Nature' },
114
+ { emoji: '🐵', keywords: ['monkey'], category: 'Nature' },
115
+ { emoji: '🐔', keywords: ['chicken'], category: 'Nature' },
116
+ { emoji: '🐧', keywords: ['penguin'], category: 'Nature' },
117
+ { emoji: '🐦', keywords: ['bird'], category: 'Nature' },
118
+ { emoji: '🦋', keywords: ['butterfly'], category: 'Nature' },
119
+ { emoji: '🐝', keywords: ['bee', 'insect'], category: 'Nature' },
120
+ { emoji: '🐢', keywords: ['turtle'], category: 'Nature' },
121
+ { emoji: '🐬', keywords: ['dolphin'], category: 'Nature' },
122
+ { emoji: '🐳', keywords: ['whale'], category: 'Nature' },
123
+ { emoji: '🦄', keywords: ['unicorn'], category: 'Nature' },
124
+ { emoji: '🌵', keywords: ['cactus', 'plant'], category: 'Nature' },
125
+ { emoji: '🌲', keywords: ['tree', 'evergreen'], category: 'Nature' },
126
+ { emoji: '🌴', keywords: ['palm tree'], category: 'Nature' },
127
+ { emoji: '🌸', keywords: ['blossom', 'flower', 'cherry'], category: 'Nature' },
128
+ { emoji: '🌻', keywords: ['sunflower', 'flower'], category: 'Nature' },
129
+ { emoji: '🌈', keywords: ['rainbow'], category: 'Nature' },
130
+ { emoji: '☀️', keywords: ['sun', 'sunny'], category: 'Nature' },
131
+ { emoji: '⭐', keywords: ['star'], category: 'Nature' },
132
+ { emoji: '🔥', keywords: ['fire', 'hot', 'lit'], category: 'Nature' },
133
+ { emoji: '🌊', keywords: ['wave', 'ocean', 'water'], category: 'Nature' },
134
+ { emoji: '❄️', keywords: ['snowflake', 'snow', 'cold'], category: 'Nature' },
135
+
136
+ // Food
137
+ { emoji: '🍎', keywords: ['apple', 'fruit'], category: 'Food' },
138
+ { emoji: '🍌', keywords: ['banana', 'fruit'], category: 'Food' },
139
+ { emoji: '🍇', keywords: ['grapes', 'fruit'], category: 'Food' },
140
+ { emoji: '🍓', keywords: ['strawberry', 'fruit'], category: 'Food' },
141
+ { emoji: '🍉', keywords: ['watermelon', 'fruit'], category: 'Food' },
142
+ { emoji: '🍕', keywords: ['pizza'], category: 'Food' },
143
+ { emoji: '🍔', keywords: ['burger', 'hamburger'], category: 'Food' },
144
+ { emoji: '🍟', keywords: ['fries', 'french fries'], category: 'Food' },
145
+ { emoji: '🌭', keywords: ['hot dog'], category: 'Food' },
146
+ { emoji: '🌮', keywords: ['taco'], category: 'Food' },
147
+ { emoji: '🍣', keywords: ['sushi'], category: 'Food' },
148
+ { emoji: '🍜', keywords: ['noodles', 'ramen', 'soup'], category: 'Food' },
149
+ { emoji: '🍝', keywords: ['pasta', 'spaghetti'], category: 'Food' },
150
+ { emoji: '🍞', keywords: ['bread'], category: 'Food' },
151
+ { emoji: '🥐', keywords: ['croissant'], category: 'Food' },
152
+ { emoji: '🧀', keywords: ['cheese'], category: 'Food' },
153
+ { emoji: '🍗', keywords: ['chicken leg', 'meat'], category: 'Food' },
154
+ { emoji: '🍩', keywords: ['donut', 'doughnut'], category: 'Food' },
155
+ { emoji: '🍪', keywords: ['cookie'], category: 'Food' },
156
+ { emoji: '🎂', keywords: ['cake', 'birthday'], category: 'Food' },
157
+ { emoji: '🍰', keywords: ['cake', 'slice'], category: 'Food' },
158
+ { emoji: '🍫', keywords: ['chocolate'], category: 'Food' },
159
+ { emoji: '🍿', keywords: ['popcorn'], category: 'Food' },
160
+ { emoji: '☕', keywords: ['coffee', 'hot drink'], category: 'Food' },
161
+ { emoji: '🍵', keywords: ['tea'], category: 'Food' },
162
+ { emoji: '🍺', keywords: ['beer'], category: 'Food' },
163
+ { emoji: '🍷', keywords: ['wine'], category: 'Food' },
164
+ { emoji: '🥤', keywords: ['drink', 'soda', 'cup'], category: 'Food' },
165
+
166
+ // Activity
167
+ { emoji: '⚽', keywords: ['soccer', 'football'], category: 'Activity' },
168
+ { emoji: '🏀', keywords: ['basketball'], category: 'Activity' },
169
+ { emoji: '🏈', keywords: ['american football'], category: 'Activity' },
170
+ { emoji: '⚾', keywords: ['baseball'], category: 'Activity' },
171
+ { emoji: '🎾', keywords: ['tennis'], category: 'Activity' },
172
+ { emoji: '🏐', keywords: ['volleyball'], category: 'Activity' },
173
+ { emoji: '🏓', keywords: ['ping pong', 'table tennis'], category: 'Activity' },
174
+ { emoji: '🏸', keywords: ['badminton'], category: 'Activity' },
175
+ { emoji: '🥊', keywords: ['boxing', 'glove'], category: 'Activity' },
176
+ { emoji: '🏋️', keywords: ['weightlifting', 'gym', 'workout'], category: 'Activity' },
177
+ { emoji: '🚴', keywords: ['cycling', 'bike'], category: 'Activity' },
178
+ { emoji: '🏊', keywords: ['swimming'], category: 'Activity' },
179
+ { emoji: '🧗', keywords: ['climbing'], category: 'Activity' },
180
+ { emoji: '🏆', keywords: ['trophy', 'win', 'award'], category: 'Activity' },
181
+ { emoji: '🥇', keywords: ['gold medal', 'first place'], category: 'Activity' },
182
+ { emoji: '🎮', keywords: ['video game', 'controller', 'gaming'], category: 'Activity' },
183
+ { emoji: '🎲', keywords: ['dice', 'game'], category: 'Activity' },
184
+ { emoji: '🎯', keywords: ['target', 'dart', 'goal'], category: 'Activity' },
185
+ { emoji: '🎨', keywords: ['art', 'palette', 'paint'], category: 'Activity' },
186
+ { emoji: '🎸', keywords: ['guitar', 'music'], category: 'Activity' },
187
+ { emoji: '🎧', keywords: ['headphones', 'music'], category: 'Activity' },
188
+ { emoji: '🎤', keywords: ['microphone', 'sing', 'karaoke'], category: 'Activity' },
189
+
190
+ // Travel
191
+ { emoji: '🚗', keywords: ['car', 'drive'], category: 'Travel' },
192
+ { emoji: '🚕', keywords: ['taxi', 'cab'], category: 'Travel' },
193
+ { emoji: '🚌', keywords: ['bus'], category: 'Travel' },
194
+ { emoji: '🚓', keywords: ['police car'], category: 'Travel' },
195
+ { emoji: '🚑', keywords: ['ambulance'], category: 'Travel' },
196
+ { emoji: '🚲', keywords: ['bike', 'bicycle'], category: 'Travel' },
197
+ { emoji: '🏍️', keywords: ['motorcycle'], category: 'Travel' },
198
+ { emoji: '✈️', keywords: ['airplane', 'flight', 'travel'], category: 'Travel' },
199
+ { emoji: '🚀', keywords: ['rocket', 'launch', 'space'], category: 'Travel' },
200
+ { emoji: '🚁', keywords: ['helicopter'], category: 'Travel' },
201
+ { emoji: '🚢', keywords: ['ship', 'boat', 'cruise'], category: 'Travel' },
202
+ { emoji: '⛵', keywords: ['sailboat', 'boat'], category: 'Travel' },
203
+ { emoji: '🚆', keywords: ['train'], category: 'Travel' },
204
+ { emoji: '🚇', keywords: ['subway', 'metro'], category: 'Travel' },
205
+ { emoji: '🗽', keywords: ['statue of liberty', 'landmark'], category: 'Travel' },
206
+ { emoji: '🗼', keywords: ['tower', 'landmark'], category: 'Travel' },
207
+ { emoji: '🏝️', keywords: ['island', 'beach', 'vacation'], category: 'Travel' },
208
+ { emoji: '🏖️', keywords: ['beach', 'vacation'], category: 'Travel' },
209
+ { emoji: '🏔️', keywords: ['mountain', 'snow'], category: 'Travel' },
210
+ { emoji: '🗺️', keywords: ['map', 'travel'], category: 'Travel' },
211
+ { emoji: '🧳', keywords: ['luggage', 'suitcase', 'packing'], category: 'Travel' },
212
+ { emoji: '🏨', keywords: ['hotel'], category: 'Travel' },
213
+ { emoji: '⛺', keywords: ['tent', 'camping'], category: 'Travel' },
214
+
215
+ // Objects
216
+ { emoji: '💡', keywords: ['idea', 'lightbulb', 'bulb'], category: 'Objects' },
217
+ { emoji: '📱', keywords: ['phone', 'mobile', 'smartphone'], category: 'Objects' },
218
+ { emoji: '💻', keywords: ['laptop', 'computer'], category: 'Objects' },
219
+ { emoji: '⌚', keywords: ['watch', 'clock'], category: 'Objects' },
220
+ { emoji: '📷', keywords: ['camera', 'photo'], category: 'Objects' },
221
+ { emoji: '🎁', keywords: ['gift', 'present'], category: 'Objects' },
222
+ { emoji: '📚', keywords: ['books', 'read', 'study'], category: 'Objects' },
223
+ { emoji: '✏️', keywords: ['pencil', 'write'], category: 'Objects' },
224
+ { emoji: '📝', keywords: ['note', 'memo', 'write'], category: 'Objects' },
225
+ { emoji: '📌', keywords: ['pin', 'pushpin'], category: 'Objects' },
226
+ { emoji: '🔒', keywords: ['lock', 'secure', 'private'], category: 'Objects' },
227
+ { emoji: '🔑', keywords: ['key', 'unlock'], category: 'Objects' },
228
+ { emoji: '🔨', keywords: ['hammer', 'tool', 'build'], category: 'Objects' },
229
+ { emoji: '💰', keywords: ['money', 'bag', 'cash'], category: 'Objects' },
230
+ { emoji: '💳', keywords: ['credit card', 'payment'], category: 'Objects' },
231
+ { emoji: '📦', keywords: ['box', 'package', 'delivery'], category: 'Objects' },
232
+ { emoji: '📅', keywords: ['calendar', 'date'], category: 'Objects' },
233
+ { emoji: '⏰', keywords: ['alarm', 'clock', 'time'], category: 'Objects' },
234
+ { emoji: '🔔', keywords: ['bell', 'notification', 'alert'], category: 'Objects' },
235
+ { emoji: '🎵', keywords: ['music', 'note'], category: 'Objects' },
236
+
237
+ // Symbols
238
+ { emoji: '❤️', keywords: ['heart', 'love', 'red'], category: 'Symbols' },
239
+ { emoji: '💔', keywords: ['broken heart', 'sad'], category: 'Symbols' },
240
+ { emoji: '💯', keywords: ['hundred', 'perfect', 'score'], category: 'Symbols' },
241
+ { emoji: '✅', keywords: ['check', 'done', 'yes', 'correct'], category: 'Symbols' },
242
+ { emoji: '❌', keywords: ['cross', 'no', 'wrong', 'cancel'], category: 'Symbols' },
243
+ { emoji: '⚠️', keywords: ['warning', 'caution', 'alert'], category: 'Symbols' },
244
+ { emoji: '❓', keywords: ['question', 'ask'], category: 'Symbols' },
245
+ { emoji: '❗', keywords: ['exclamation', 'important'], category: 'Symbols' },
246
+ { emoji: '♻️', keywords: ['recycle', 'reuse'], category: 'Symbols' },
247
+ { emoji: '⚡', keywords: ['lightning', 'bolt', 'fast', 'power'], category: 'Symbols' },
248
+ { emoji: '✨', keywords: ['sparkles', 'shiny', 'magic'], category: 'Symbols' },
249
+ { emoji: '🎉', keywords: ['party', 'celebrate', 'tada'], category: 'Symbols' },
250
+ { emoji: '🔁', keywords: ['repeat', 'loop', 'refresh'], category: 'Symbols' },
251
+ { emoji: '🔀', keywords: ['shuffle', 'random'], category: 'Symbols' },
252
+ { emoji: '➕', keywords: ['plus', 'add'], category: 'Symbols' },
253
+ { emoji: '➖', keywords: ['minus', 'subtract'], category: 'Symbols' },
254
+ { emoji: '🆗', keywords: ['ok', 'okay'], category: 'Symbols' },
255
+ { emoji: '🔴', keywords: ['red circle', 'stop', 'live'], category: 'Symbols' },
256
+ { emoji: '🟢', keywords: ['green circle', 'go', 'online'], category: 'Symbols' },
257
+ ]
258
+
259
+ const MAX_RECENTS = 16
260
+
261
+ export interface EmojiPickerProps {
262
+ /** Called with the emoji character whenever the user picks one. */
263
+ onSelect: (emoji: string) => void
264
+ class?: string
265
+ }
266
+
267
+ /**
268
+ * A searchable emoji grid with an inline curated dataset (no network fetch),
269
+ * category tabs that scroll to their section, and a session-only "Recents"
270
+ * row. Type in the search box to filter by keyword, or click a category icon
271
+ * to jump the grid to that section.
272
+ *
273
+ * @example
274
+ * ```tsx
275
+ * <EmojiPicker onSelect={(emoji) => insertAtCursor(emoji)} />
276
+ * ```
277
+ */
278
+ export function EmojiPicker(props: EmojiPickerProps): JSX.Element {
279
+ const [query, setQuery] = createSignal('')
280
+ const [recents, setRecents] = createSignal<string[]>([])
281
+
282
+ const sectionRefs = new Map<Category, HTMLDivElement>()
283
+ let scrollRoot!: HTMLDivElement
284
+
285
+ const filtered = createMemo<EmojiEntry[]>(() => {
286
+ const q = query().trim().toLowerCase()
287
+ if (!q) return EMOJI_DATA
288
+ return EMOJI_DATA.filter(
289
+ (entry) => entry.emoji === q || entry.keywords.some((keyword) => keyword.includes(q)),
290
+ )
291
+ })
292
+
293
+ const grouped = createMemo<Array<[Category, EmojiEntry[]]>>(() => {
294
+ const byCategory = new Map<Category, EmojiEntry[]>()
295
+ for (const entry of filtered()) {
296
+ const list = byCategory.get(entry.category)
297
+ if (list) list.push(entry)
298
+ else byCategory.set(entry.category, [entry])
299
+ }
300
+ return CATEGORIES.filter((category) => byCategory.has(category)).map(
301
+ (category) => [category, byCategory.get(category) ?? []] as [Category, EmojiEntry[]],
302
+ )
303
+ })
304
+
305
+ const jumpTo = (category: Category): void => {
306
+ sectionRefs.get(category)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
307
+ }
308
+
309
+ const pick = (emoji: string): void => {
310
+ setRecents((prev) => [emoji, ...prev.filter((item) => item !== emoji)].slice(0, MAX_RECENTS))
311
+ props.onSelect(emoji)
312
+ }
313
+
314
+ const onGridKeyDown = (event: KeyboardEvent): void => {
315
+ if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
316
+ const target = event.target as HTMLElement
317
+ if (target.tagName !== 'BUTTON') return
318
+ const buttons = Array.from(scrollRoot.querySelectorAll<HTMLButtonElement>('button[data-emoji-cell]'))
319
+ const index = buttons.indexOf(target as HTMLButtonElement)
320
+ if (index === -1) return
321
+ const columns = 8
322
+ const delta =
323
+ event.key === 'ArrowRight'
324
+ ? 1
325
+ : event.key === 'ArrowLeft'
326
+ ? -1
327
+ : event.key === 'ArrowDown'
328
+ ? columns
329
+ : -columns
330
+ const next = buttons[index + delta]
331
+ if (next) {
332
+ event.preventDefault()
333
+ next.focus()
334
+ }
335
+ }
336
+
337
+ return (
338
+ <div
339
+ class={cn('flex w-full max-w-sm flex-col overflow-hidden rounded-md border border-border', props.class)}
340
+ >
341
+ <div class="flex items-center gap-2 border-b border-border bg-background p-2">
342
+ <Search class="h-4 w-4 shrink-0 text-muted-foreground" />
343
+ <input
344
+ type="text"
345
+ value={query()}
346
+ onInput={(ev) => setQuery(ev.currentTarget.value)}
347
+ placeholder="Search emoji…"
348
+ aria-label="Search emoji"
349
+ class="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
350
+ />
351
+ </div>
352
+
353
+ <div class="flex items-center gap-1 overflow-x-auto border-b border-border bg-background px-2 py-1.5">
354
+ <For each={CATEGORIES}>
355
+ {(category) => {
356
+ const Icon = CATEGORY_ICON[category]
357
+ return (
358
+ <button
359
+ type="button"
360
+ onClick={() => jumpTo(category)}
361
+ aria-label={category}
362
+ title={category}
363
+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
364
+ >
365
+ <Icon class="h-4 w-4" />
366
+ </button>
367
+ )
368
+ }}
369
+ </For>
370
+ </div>
371
+
372
+ <div ref={scrollRoot} onKeyDown={onGridKeyDown} class="max-h-72 overflow-y-auto p-2">
373
+ <Show when={recents().length > 0 && !query()}>
374
+ <div class="mb-2">
375
+ <p class="mb-1 px-1 text-xs font-medium text-muted-foreground">Recents</p>
376
+ <div class="grid grid-cols-8 gap-0.5">
377
+ <For each={recents()}>
378
+ {(emoji) => (
379
+ <button
380
+ type="button"
381
+ data-emoji-cell
382
+ onClick={() => pick(emoji)}
383
+ aria-label={emoji}
384
+ class="flex h-8 w-8 items-center justify-center rounded-md text-lg leading-none transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
385
+ >
386
+ {emoji}
387
+ </button>
388
+ )}
389
+ </For>
390
+ </div>
391
+ </div>
392
+ </Show>
393
+
394
+ <Show when={grouped().length === 0}>
395
+ <p class="px-1 py-6 text-center text-sm text-muted-foreground">No emoji found.</p>
396
+ </Show>
397
+
398
+ <For each={grouped()}>
399
+ {([category, entries]) => (
400
+ <div ref={(el) => sectionRefs.set(category, el)} class="mb-2">
401
+ <p class="mb-1 px-1 text-xs font-medium text-muted-foreground">{category}</p>
402
+ <div class="grid grid-cols-8 gap-0.5">
403
+ <For each={entries}>
404
+ {(entry) => (
405
+ <button
406
+ type="button"
407
+ data-emoji-cell
408
+ onClick={() => pick(entry.emoji)}
409
+ aria-label={entry.keywords[0] ?? entry.emoji}
410
+ title={entry.keywords[0] ?? entry.emoji}
411
+ class="flex h-8 w-8 items-center justify-center rounded-md text-lg leading-none transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
412
+ >
413
+ {entry.emoji}
414
+ </button>
415
+ )}
416
+ </For>
417
+ </div>
418
+ </div>
419
+ )}
420
+ </For>
421
+ </div>
422
+ </div>
423
+ )
424
+ }