@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.
- package/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- 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
|
+
}
|