@a4ui/core 0.33.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.
Files changed (58) hide show
  1. package/dist/elements.css +299 -0
  2. package/dist/full.css +299 -0
  3. package/dist/index.d.ts +27 -1
  4. package/dist/index.js +9140 -5065
  5. package/dist/ui/ActivityFeed.d.ts +49 -0
  6. package/dist/ui/AudioWaveform.d.ts +22 -0
  7. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  8. package/dist/ui/CodeEditor.d.ts +25 -0
  9. package/dist/ui/CouponField.d.ts +33 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/FollowingPointer.d.ts +24 -0
  13. package/dist/ui/GanttChart.d.ts +37 -0
  14. package/dist/ui/InteractiveMap.d.ts +50 -0
  15. package/dist/ui/JsonViewer.d.ts +24 -0
  16. package/dist/ui/Kanban.d.ts +49 -0
  17. package/dist/ui/Lamp.d.ts +22 -0
  18. package/dist/ui/Lightbox.d.ts +41 -0
  19. package/dist/ui/LocationPicker.d.ts +25 -0
  20. package/dist/ui/MaskedInput.d.ts +26 -0
  21. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  22. package/dist/ui/OtpInput.d.ts +29 -0
  23. package/dist/ui/PivotTable.d.ts +37 -0
  24. package/dist/ui/PresenceAvatars.d.ts +54 -0
  25. package/dist/ui/QueryBuilder.d.ts +56 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/SignaturePad.d.ts +19 -0
  28. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  29. package/dist/ui/TreeTable.d.ts +52 -0
  30. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  31. package/package.json +1 -1
  32. package/src/index.ts +60 -1
  33. package/src/ui/ActivityFeed.tsx +178 -0
  34. package/src/ui/AudioWaveform.tsx +190 -0
  35. package/src/ui/AvailabilityPicker.tsx +163 -0
  36. package/src/ui/CodeEditor.tsx +277 -0
  37. package/src/ui/CouponField.tsx +120 -0
  38. package/src/ui/EmojiPicker.tsx +424 -0
  39. package/src/ui/EventScheduler.tsx +280 -0
  40. package/src/ui/FollowingPointer.tsx +112 -0
  41. package/src/ui/GanttChart.tsx +283 -0
  42. package/src/ui/InteractiveMap.tsx +349 -0
  43. package/src/ui/JsonViewer.tsx +189 -0
  44. package/src/ui/Kanban.tsx +250 -0
  45. package/src/ui/Lamp.tsx +88 -0
  46. package/src/ui/Lightbox.tsx +261 -0
  47. package/src/ui/LocationPicker.tsx +96 -0
  48. package/src/ui/MaskedInput.tsx +108 -0
  49. package/src/ui/OnboardingChecklist.tsx +163 -0
  50. package/src/ui/OtpInput.tsx +153 -0
  51. package/src/ui/PivotTable.tsx +0 -0
  52. package/src/ui/PresenceAvatars.tsx +142 -0
  53. package/src/ui/QueryBuilder.tsx +280 -0
  54. package/src/ui/SheetSnap.tsx +264 -0
  55. package/src/ui/SignaturePad.tsx +221 -0
  56. package/src/ui/SpreadsheetGrid.tsx +304 -0
  57. package/src/ui/TreeTable.tsx +172 -0
  58. package/src/ui/VideoPlayerShell.tsx +282 -0
@@ -0,0 +1,120 @@
1
+ // Input + Apply button with explicit idle/loading/success/error states.
2
+ // Rejected coupons surface as { ok: false } and render inline; unexpected
3
+ // rejections from `onApply` are not caught here, so they propagate as
4
+ // unhandled rejections instead of being silently swallowed.
5
+ import { CircleCheck, X } from 'lucide-solid'
6
+ import type { JSX } from 'solid-js'
7
+ import { createSignal, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { Button } from './Button'
11
+ import { Input } from './Input'
12
+ import { Spinner } from './Spinner'
13
+
14
+ export interface CouponFieldProps {
15
+ /** Validate/apply a coupon code. Resolve with `{ ok: false }` for a rejected
16
+ * code — do not throw for expected rejections, only for unexpected failures. */
17
+ onApply: (code: string) => Promise<{ ok: boolean; message?: string; discount?: string }>
18
+ placeholder?: string
19
+ class?: string
20
+ }
21
+
22
+ type State =
23
+ | { status: 'idle' | 'loading' }
24
+ | { status: 'success'; code: string; discount?: string }
25
+ | { status: 'error'; message: string }
26
+
27
+ /**
28
+ * Coupon/promo code input with an "Apply" button. Tracks explicit
29
+ * idle/loading/success/error state: loading shows a spinner and disables the
30
+ * input, success shows the applied discount with a way to remove it and try
31
+ * another code, error shows the failure message inline and lets the user retry.
32
+ * Rejections are read from the resolved `{ ok: false }` result, not caught
33
+ * exceptions — an unexpected throw from `onApply` propagates uncaught.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <CouponField
38
+ * onApply={async (code) => {
39
+ * const res = await api.applyCoupon(code)
40
+ * return res.valid
41
+ * ? { ok: true, discount: '-$10' }
42
+ * : { ok: false, message: 'That code has expired.' }
43
+ * }}
44
+ * />
45
+ * ```
46
+ */
47
+ export function CouponField(props: CouponFieldProps): JSX.Element {
48
+ const [code, setCode] = createSignal('')
49
+ const [state, setState] = createSignal<State>({ status: 'idle' })
50
+
51
+ const apply = async (): Promise<void> => {
52
+ const value = code().trim()
53
+ if (!value || state().status === 'loading') return
54
+ setState({ status: 'loading' })
55
+ const result = await props.onApply(value)
56
+ if (result.ok) {
57
+ setState({ status: 'success', code: value, discount: result.discount })
58
+ } else {
59
+ setState({ status: 'error', message: result.message ?? 'This code could not be applied.' })
60
+ }
61
+ }
62
+
63
+ const remove = (): void => {
64
+ setCode('')
65
+ setState({ status: 'idle' })
66
+ }
67
+
68
+ const isLoading = () => state().status === 'loading'
69
+ const isSuccess = () => state().status === 'success'
70
+
71
+ return (
72
+ <div class={cn('flex flex-col gap-2', props.class)}>
73
+ <Show
74
+ when={!isSuccess()}
75
+ fallback={
76
+ <div class="flex items-center justify-between rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm">
77
+ <span class="flex items-center gap-2 text-emerald-500">
78
+ <CircleCheck class="h-4 w-4 shrink-0" />
79
+ <span class="text-foreground">
80
+ {(state() as { status: 'success'; code: string; discount?: string }).discount ?? 'Discount'}{' '}
81
+ applied
82
+ </span>
83
+ </span>
84
+ <button
85
+ type="button"
86
+ aria-label="Remove coupon"
87
+ onClick={remove}
88
+ class="text-muted-foreground hover:text-foreground"
89
+ >
90
+ <X class="h-4 w-4" />
91
+ </button>
92
+ </div>
93
+ }
94
+ >
95
+ <div class="flex gap-2">
96
+ <Input
97
+ value={code()}
98
+ onInput={setCode}
99
+ disabled={isLoading()}
100
+ placeholder={props.placeholder ?? 'Coupon code'}
101
+ onKeyDown={(ev) => {
102
+ if (ev.key === 'Enter') {
103
+ ev.preventDefault()
104
+ void apply()
105
+ }
106
+ }}
107
+ />
108
+ <Button variant="outline" disabled={isLoading() || !code().trim()} onClick={() => void apply()}>
109
+ <Show when={isLoading()} fallback="Apply">
110
+ <Spinner class="h-4 w-4" label="Applying coupon" />
111
+ </Show>
112
+ </Button>
113
+ </div>
114
+ <Show when={state().status === 'error'}>
115
+ <p class="text-sm text-destructive">{(state() as { status: 'error'; message: string }).message}</p>
116
+ </Show>
117
+ </Show>
118
+ </div>
119
+ )
120
+ }
@@ -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
+ }