Compare commits

...

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
e9965abdb9 feat(EmojiTextArea): add Slack-like emoji autocomplete component
Introduces a new EmojiTextArea component with Slack-like emoji autocomplete
behavior:

- Triggers on `:` prefix with 2+ character minimum (configurable)
- Smart trigger detection: colon must be preceded by whitespace, start of
  text, or another emoji (prevents false positives like URLs)
- Prevents accidental Enter key selection when typing quickly
- Includes 400+ curated emojis with shortcodes and keyword search
- Fully typed with TypeScript, includes tests and Storybook stories

Usage:
```tsx
<EmojiTextArea
  placeholder="Type 😄 to add emojis..."
  onChange={(text) => console.log(text)}
  minCharsBeforePopup={2}
/>
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 21:34:02 +00:00
5 changed files with 1324 additions and 0 deletions

View File

@@ -0,0 +1,331 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { EmojiTextArea, type EmojiItem } from '.';
const meta: Meta<typeof EmojiTextArea> = {
title: 'Components/EmojiTextArea',
component: EmojiTextArea,
parameters: {
docs: {
description: {
component: `
A TextArea component with Slack-like emoji autocomplete.
## Features
- **Colon prefix trigger**: Type \`:sm\` to see smile emoji suggestions
- **Minimum 2 characters**: Popup only shows after typing 2+ characters (configurable)
- **Smart trigger detection**: Colon must be preceded by whitespace, start of line, or another emoji
- **Prevents accidental selection**: Quick Enter keypress creates newline instead of selecting
## Usage
\`\`\`tsx
import { EmojiTextArea } from '@superset-ui/core/components';
<EmojiTextArea
placeholder="Type :smile: to add emojis..."
onChange={(text) => console.log(text)}
onEmojiSelect={(emoji) => console.log('Selected:', emoji)}
/>
\`\`\`
## Trigger Behavior (Slack-like)
The emoji picker triggers in these scenarios:
- \`:sm\` - at the start of text
- \`hello :sm\` - after a space
- \`😀:sm\` - after another emoji
It does NOT trigger in:
- \`hello:sm\` - no space before colon
- \`http://example.com\` - colon preceded by letter
Try it out below!
`,
},
},
},
argTypes: {
minCharsBeforePopup: {
control: { type: 'number', min: 1, max: 5 },
description: 'Minimum characters after colon before showing popup',
defaultValue: 2,
},
maxSuggestions: {
control: { type: 'number', min: 1, max: 20 },
description: 'Maximum number of emoji suggestions to show',
defaultValue: 10,
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
rows: {
control: { type: 'number', min: 1, max: 20 },
description: 'Number of visible rows',
},
},
};
export default meta;
type Story = StoryObj<typeof EmojiTextArea>;
export const Default: Story = {
args: {
placeholder: 'Type :smile: or :thumbsup: to add emojis...',
rows: 4,
style: { width: '100%', maxWidth: 500 },
},
};
export const WithMinChars: Story = {
args: {
...Default.args,
minCharsBeforePopup: 3,
placeholder: 'Requires 3 characters after colon (e.g., :smi)',
},
};
export const WithMaxSuggestions: Story = {
args: {
...Default.args,
maxSuggestions: 5,
placeholder: 'Shows max 5 suggestions',
},
};
export const Controlled: Story = {
render: function ControlledStory() {
const [value, setValue] = useState('');
const [selectedEmojis, setSelectedEmojis] = useState<EmojiItem[]>([]);
return (
<div style={{ maxWidth: 500 }}>
<EmojiTextArea
value={value}
onChange={setValue}
onEmojiSelect={emoji => setSelectedEmojis(prev => [...prev, emoji])}
placeholder="Type :smile: or :heart: to add emojis..."
rows={4}
style={{ width: '100%' }}
/>
<div style={{ marginTop: 16 }}>
<strong>Current value:</strong>
<pre
style={{
background: 'var(--ant-color-bg-container)',
padding: 8,
borderRadius: 4,
border: '1px solid var(--ant-color-border)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{value || '(empty)'}
</pre>
</div>
{selectedEmojis.length > 0 && (
<div style={{ marginTop: 16 }}>
<strong>Selected emojis:</strong>
<div style={{ fontSize: 24, marginTop: 8 }}>
{selectedEmojis.map((e, i) => (
<span key={i} title={`:${e.shortcode}:`}>
{e.emoji}
</span>
))}
</div>
</div>
)}
</div>
);
},
};
export const SlackBehaviorDemo: Story = {
render: function SlackBehaviorDemoStory() {
const examples = [
{ input: ':sm', works: true, desc: 'Start of text' },
{ input: 'hello :sm', works: true, desc: 'After space' },
{
input: '😀:sm',
works: true,
desc: 'After emoji',
needsEmoji: true,
},
{ input: 'hello:sm', works: false, desc: 'No space before colon' },
{ input: ':s', works: false, desc: 'Only 1 character' },
];
return (
<div style={{ maxWidth: 600 }}>
<h3>Slack-like Trigger Behavior</h3>
<p style={{ color: 'var(--ant-color-text-secondary)' }}>
The emoji picker mimics Slack&apos;s behavior. Try these examples:
</p>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: 24,
}}
>
<thead>
<tr>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Input
</th>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Shows Popup?
</th>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Reason
</th>
</tr>
</thead>
<tbody>
{examples.map((ex, i) => (
<tr key={i}>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
fontFamily: 'monospace',
}}
>
{ex.input}
</td>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
{ex.works ? '✅ Yes' : '❌ No'}
</td>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
{ex.desc}
</td>
</tr>
))}
</tbody>
</table>
<EmojiTextArea
placeholder="Try the examples above..."
rows={4}
style={{ width: '100%' }}
/>
</div>
);
},
};
export const InForm: Story = {
render: function InFormStory() {
const [description, setDescription] = useState('');
const [title, setTitle] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// eslint-disable-next-line no-alert
alert(`Title: ${title}\nDescription: ${description}`);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 500 }}>
<div style={{ marginBottom: 16 }}>
<label htmlFor="title" style={{ display: 'block', marginBottom: 4 }}>
Title
</label>
<input
id="title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Enter a title"
style={{
width: '100%',
padding: 8,
borderRadius: 4,
border: '1px solid var(--ant-color-border)',
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label
htmlFor="description"
style={{ display: 'block', marginBottom: 4 }}
>
Description (with emoji support)
</label>
<EmojiTextArea
id="description"
value={description}
onChange={setDescription}
placeholder="Add a description... use :smile: for emojis!"
rows={4}
style={{ width: '100%' }}
/>
</div>
<button
type="submit"
style={{
padding: '8px 16px',
background: 'var(--ant-color-primary)',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
}}
>
Submit
</button>
</form>
);
},
};

View File

@@ -0,0 +1,170 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { EmojiTextArea } from '.';
import { filterEmojis, EMOJI_DATA } from './emojiData';
test('renders EmojiTextArea with placeholder', () => {
render(<EmojiTextArea placeholder="Type something..." />);
expect(screen.getByPlaceholderText('Type something...')).toBeInTheDocument();
});
test('renders EmojiTextArea as textarea element', () => {
render(<EmojiTextArea placeholder="Type here" />);
const textarea = screen.getByPlaceholderText('Type here');
expect(textarea.tagName.toLowerCase()).toBe('textarea');
});
test('allows typing in the textarea', async () => {
render(<EmojiTextArea placeholder="Type here" />);
const textarea = screen.getByPlaceholderText('Type here');
await userEvent.type(textarea, 'Hello world');
expect(textarea).toHaveValue('Hello world');
});
test('calls onChange when typing', async () => {
const onChange = jest.fn();
render(<EmojiTextArea placeholder="Type here" onChange={onChange} />);
const textarea = screen.getByPlaceholderText('Type here');
await userEvent.type(textarea, 'Hi');
expect(onChange).toHaveBeenCalled();
});
test('passes through rows prop', () => {
render(<EmojiTextArea placeholder="Type here" rows={5} />);
const textarea = screen.getByPlaceholderText('Type here');
expect(textarea).toHaveAttribute('rows', '5');
});
test('forwards ref to underlying component', () => {
const ref = { current: null };
render(<EmojiTextArea ref={ref} placeholder="Type here" />);
expect(ref.current).not.toBeNull();
});
test('renders controlled component with value prop', () => {
render(<EmojiTextArea value="Hello" onChange={() => {}} />);
expect(screen.getByDisplayValue('Hello')).toBeInTheDocument();
});
// ============================================
// Unit tests for filterEmojis utility function
// ============================================
test('filterEmojis returns matching emojis by shortcode', () => {
const results = filterEmojis('smile');
expect(results.length).toBeGreaterThan(0);
expect(results[0].shortcode).toBe('smile');
});
test('filterEmojis returns matching emojis by partial shortcode', () => {
const results = filterEmojis('sm');
expect(results.length).toBeGreaterThan(0);
// Should include smile, smirk, etc.
expect(results.some(e => e.shortcode.includes('sm'))).toBe(true);
});
test('filterEmojis returns matching emojis by keyword', () => {
const results = filterEmojis('happy');
expect(results.length).toBeGreaterThan(0);
// Should include emojis with 'happy' keyword
expect(results.some(e => e.keywords?.includes('happy'))).toBe(true);
});
test('filterEmojis is case insensitive', () => {
const results1 = filterEmojis('SMILE');
const results2 = filterEmojis('smile');
expect(results1.length).toBe(results2.length);
expect(results1[0].shortcode).toBe(results2[0].shortcode);
});
test('filterEmojis respects limit parameter', () => {
const results = filterEmojis('a', 5);
expect(results.length).toBeLessThanOrEqual(5);
});
test('filterEmojis returns empty array for empty search', () => {
const results = filterEmojis('');
expect(results).toEqual([]);
});
test('filterEmojis returns empty array for no matches', () => {
const results = filterEmojis('zzzznotanemoji');
expect(results).toEqual([]);
});
// ============================================
// Unit tests for EMOJI_DATA
// ============================================
test('EMOJI_DATA contains expected smileys', () => {
const smile = EMOJI_DATA.find(e => e.shortcode === 'smile');
expect(smile).toBeDefined();
expect(smile?.emoji).toBe('😄');
const joy = EMOJI_DATA.find(e => e.shortcode === 'joy');
expect(joy).toBeDefined();
expect(joy?.emoji).toBe('😂');
});
test('EMOJI_DATA contains expected gestures', () => {
const thumbsup = EMOJI_DATA.find(e => e.shortcode === 'thumbsup');
expect(thumbsup).toBeDefined();
expect(thumbsup?.emoji).toBe('👍');
const clap = EMOJI_DATA.find(e => e.shortcode === 'clap');
expect(clap).toBeDefined();
expect(clap?.emoji).toBe('👏');
});
test('EMOJI_DATA contains expected symbols', () => {
const heart = EMOJI_DATA.find(e => e.shortcode === 'heart');
expect(heart).toBeDefined();
expect(heart?.emoji).toBe('❤️');
const fire = EMOJI_DATA.find(e => e.shortcode === 'fire');
expect(fire).toBeDefined();
expect(fire?.emoji).toBe('🔥');
const checkmark = EMOJI_DATA.find(e => e.shortcode === 'white_check_mark');
expect(checkmark).toBeDefined();
expect(checkmark?.emoji).toBe('✅');
});
test('EMOJI_DATA items have required properties', () => {
EMOJI_DATA.forEach(item => {
expect(item).toHaveProperty('shortcode');
expect(item).toHaveProperty('emoji');
expect(typeof item.shortcode).toBe('string');
expect(typeof item.emoji).toBe('string');
expect(item.shortcode.length).toBeGreaterThan(0);
expect(item.emoji.length).toBeGreaterThan(0);
});
});
test('EMOJI_DATA shortcodes are unique', () => {
const shortcodes = EMOJI_DATA.map(e => e.shortcode);
const uniqueShortcodes = new Set(shortcodes);
expect(uniqueShortcodes.size).toBe(shortcodes.length);
});
test('EMOJI_DATA has a reasonable number of emojis', () => {
// Ensure we have a substantial emoji set
expect(EMOJI_DATA.length).toBeGreaterThan(100);
});

View File

@@ -0,0 +1,569 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface EmojiItem {
shortcode: string;
emoji: string;
keywords?: string[];
}
/**
* Common emoji data with shortcodes.
* This is a curated subset of emojis commonly used in Slack-like applications.
* Can be extended or replaced with a more comprehensive emoji library.
*/
export const EMOJI_DATA: EmojiItem[] = [
// Smileys & Emotion
{ shortcode: 'smile', emoji: '😄', keywords: ['happy', 'joy', 'glad'] },
{ shortcode: 'smiley', emoji: '😃', keywords: ['happy', 'joy'] },
{ shortcode: 'grinning', emoji: '😀', keywords: ['happy', 'smile'] },
{ shortcode: 'blush', emoji: '😊', keywords: ['happy', 'shy', 'smile'] },
{ shortcode: 'wink', emoji: '😉', keywords: ['flirt'] },
{
shortcode: 'heart_eyes',
emoji: '😍',
keywords: ['love', 'crush', 'adore'],
},
{ shortcode: 'kissing_heart', emoji: '😘', keywords: ['love', 'kiss'] },
{ shortcode: 'laughing', emoji: '😆', keywords: ['happy', 'haha', 'lol'] },
{ shortcode: 'sweat_smile', emoji: '😅', keywords: ['nervous', 'phew'] },
{ shortcode: 'joy', emoji: '😂', keywords: ['tears', 'laugh', 'lol', 'lmao'] },
{
shortcode: 'rofl',
emoji: '🤣',
keywords: ['rolling', 'laugh', 'lol', 'lmao'],
},
{ shortcode: 'relaxed', emoji: '☺️', keywords: ['calm', 'peace'] },
{ shortcode: 'yum', emoji: '😋', keywords: ['tasty', 'delicious'] },
{ shortcode: 'relieved', emoji: '😌', keywords: ['calm', 'peaceful'] },
{ shortcode: 'sunglasses', emoji: '😎', keywords: ['cool', 'awesome'] },
{ shortcode: 'smirk', emoji: '😏', keywords: ['sly', 'confident'] },
{ shortcode: 'neutral_face', emoji: '😐', keywords: ['meh', 'blank'] },
{ shortcode: 'expressionless', emoji: '😑', keywords: ['blank', 'meh'] },
{ shortcode: 'unamused', emoji: '😒', keywords: ['bored', 'meh'] },
{ shortcode: 'sweat', emoji: '😓', keywords: ['nervous', 'worried'] },
{ shortcode: 'pensive', emoji: '😔', keywords: ['sad', 'thoughtful'] },
{ shortcode: 'confused', emoji: '😕', keywords: ['puzzled', 'unsure'] },
{ shortcode: 'upside_down', emoji: '🙃', keywords: ['silly', 'sarcasm'] },
{ shortcode: 'thinking', emoji: '🤔', keywords: ['ponder', 'hmm'] },
{ shortcode: 'zipper_mouth', emoji: '🤐', keywords: ['secret', 'quiet'] },
{ shortcode: 'raised_eyebrow', emoji: '🤨', keywords: ['skeptical', 'doubt'] },
{ shortcode: 'rolling_eyes', emoji: '🙄', keywords: ['annoyed', 'whatever'] },
{ shortcode: 'grimacing', emoji: '😬', keywords: ['awkward', 'nervous'] },
{ shortcode: 'lying_face', emoji: '🤥', keywords: ['liar', 'pinocchio'] },
{ shortcode: 'shushing', emoji: '🤫', keywords: ['quiet', 'secret'] },
{ shortcode: 'hand_over_mouth', emoji: '🤭', keywords: ['oops', 'giggle'] },
{ shortcode: 'face_vomiting', emoji: '🤮', keywords: ['sick', 'gross'] },
{ shortcode: 'exploding_head', emoji: '🤯', keywords: ['mind', 'blown'] },
{ shortcode: 'cowboy', emoji: '🤠', keywords: ['western', 'yeehaw'] },
{ shortcode: 'partying', emoji: '🥳', keywords: ['party', 'celebration'] },
{ shortcode: 'star_struck', emoji: '🤩', keywords: ['excited', 'amazed'] },
{ shortcode: 'sleeping', emoji: '😴', keywords: ['zzz', 'tired'] },
{ shortcode: 'drooling', emoji: '🤤', keywords: ['hungry', 'want'] },
{ shortcode: 'sleepy', emoji: '😪', keywords: ['tired', 'zzz'] },
{ shortcode: 'mask', emoji: '😷', keywords: ['sick', 'covid'] },
{ shortcode: 'nerd', emoji: '🤓', keywords: ['geek', 'smart'] },
{ shortcode: 'monocle', emoji: '🧐', keywords: ['curious', 'inspect'] },
{ shortcode: 'worried', emoji: '😟', keywords: ['concerned', 'anxious'] },
{ shortcode: 'frowning', emoji: '🙁', keywords: ['sad', 'unhappy'] },
{ shortcode: 'open_mouth', emoji: '😮', keywords: ['surprised', 'wow'] },
{ shortcode: 'hushed', emoji: '😯', keywords: ['surprised', 'quiet'] },
{ shortcode: 'astonished', emoji: '😲', keywords: ['shocked', 'wow'] },
{ shortcode: 'flushed', emoji: '😳', keywords: ['embarrassed', 'shy'] },
{ shortcode: 'pleading', emoji: '🥺', keywords: ['puppy', 'please'] },
{ shortcode: 'cry', emoji: '😢', keywords: ['sad', 'tear'] },
{ shortcode: 'sob', emoji: '😭', keywords: ['crying', 'sad', 'tears'] },
{ shortcode: 'scream', emoji: '😱', keywords: ['scared', 'horror'] },
{ shortcode: 'confounded', emoji: '😖', keywords: ['frustrated'] },
{ shortcode: 'persevere', emoji: '😣', keywords: ['struggling'] },
{ shortcode: 'disappointed', emoji: '😞', keywords: ['sad', 'let down'] },
{ shortcode: 'fearful', emoji: '😨', keywords: ['scared', 'afraid'] },
{ shortcode: 'cold_sweat', emoji: '😰', keywords: ['nervous', 'anxious'] },
{ shortcode: 'weary', emoji: '😩', keywords: ['tired', 'exhausted'] },
{ shortcode: 'tired_face', emoji: '😫', keywords: ['exhausted'] },
{ shortcode: 'angry', emoji: '😠', keywords: ['mad', 'grumpy'] },
{ shortcode: 'rage', emoji: '😡', keywords: ['angry', 'furious'] },
{ shortcode: 'triumph', emoji: '😤', keywords: ['proud', 'huffing'] },
{ shortcode: 'skull', emoji: '💀', keywords: ['dead', 'death'] },
{ shortcode: 'poop', emoji: '💩', keywords: ['crap', 'shit'] },
{ shortcode: 'clown', emoji: '🤡', keywords: ['funny', 'circus'] },
{ shortcode: 'imp', emoji: '👿', keywords: ['devil', 'evil'] },
{ shortcode: 'ghost', emoji: '👻', keywords: ['boo', 'spooky'] },
{ shortcode: 'alien', emoji: '👽', keywords: ['ufo', 'space'] },
{ shortcode: 'robot', emoji: '🤖', keywords: ['bot', 'machine'] },
{ shortcode: 'cat', emoji: '😺', keywords: ['kitty', 'meow'] },
{ shortcode: 'heart_eyes_cat', emoji: '😻', keywords: ['love', 'cat'] },
{ shortcode: 'joy_cat', emoji: '😹', keywords: ['laugh', 'cat'] },
{ shortcode: 'crying_cat', emoji: '😿', keywords: ['sad', 'cat'] },
{ shortcode: 'pouting_cat', emoji: '😾', keywords: ['angry', 'cat'] },
{ shortcode: 'see_no_evil', emoji: '🙈', keywords: ['monkey', 'shy'] },
{ shortcode: 'hear_no_evil', emoji: '🙉', keywords: ['monkey'] },
{ shortcode: 'speak_no_evil', emoji: '🙊', keywords: ['monkey', 'secret'] },
// Gestures & Body
{ shortcode: 'wave', emoji: '👋', keywords: ['hello', 'bye', 'hi'] },
{ shortcode: 'raised_hand', emoji: '✋', keywords: ['stop', 'high five'] },
{ shortcode: 'ok_hand', emoji: '👌', keywords: ['perfect', 'nice'] },
{ shortcode: 'pinching_hand', emoji: '🤏', keywords: ['small', 'tiny'] },
{ shortcode: 'v', emoji: '✌️', keywords: ['peace', 'victory'] },
{ shortcode: 'crossed_fingers', emoji: '🤞', keywords: ['luck', 'hope'] },
{ shortcode: 'love_you', emoji: '🤟', keywords: ['ily', 'sign'] },
{ shortcode: 'metal', emoji: '🤘', keywords: ['rock', 'horns'] },
{ shortcode: 'call_me', emoji: '🤙', keywords: ['phone', 'shaka'] },
{ shortcode: 'point_left', emoji: '👈', keywords: ['direction'] },
{ shortcode: 'point_right', emoji: '👉', keywords: ['direction'] },
{ shortcode: 'point_up', emoji: '👆', keywords: ['direction'] },
{ shortcode: 'point_down', emoji: '👇', keywords: ['direction'] },
{ shortcode: 'middle_finger', emoji: '🖕', keywords: ['flip', 'rude'] },
{ shortcode: 'thumbsup', emoji: '👍', keywords: ['yes', 'good', '+1'] },
{ shortcode: 'thumbsdown', emoji: '👎', keywords: ['no', 'bad', '-1'] },
{ shortcode: 'fist', emoji: '✊', keywords: ['power', 'punch'] },
{ shortcode: 'punch', emoji: '👊', keywords: ['fist', 'bump'] },
{ shortcode: 'clap', emoji: '👏', keywords: ['applause', 'bravo'] },
{ shortcode: 'raised_hands', emoji: '🙌', keywords: ['celebration', 'yay'] },
{ shortcode: 'open_hands', emoji: '👐', keywords: ['hug', 'open'] },
{ shortcode: 'palms_up', emoji: '🤲', keywords: ['prayer', 'request'] },
{ shortcode: 'handshake', emoji: '🤝', keywords: ['deal', 'agreement'] },
{ shortcode: 'pray', emoji: '🙏', keywords: ['please', 'thanks', 'namaste'] },
{ shortcode: 'writing', emoji: '✍️', keywords: ['write', 'pen'] },
{ shortcode: 'nail_care', emoji: '💅', keywords: ['nails', 'fabulous'] },
{ shortcode: 'selfie', emoji: '🤳', keywords: ['photo', 'camera'] },
{ shortcode: 'muscle', emoji: '💪', keywords: ['strong', 'flex', 'bicep'] },
{ shortcode: 'leg', emoji: '🦵', keywords: ['kick'] },
{ shortcode: 'foot', emoji: '🦶', keywords: ['kick', 'step'] },
{ shortcode: 'ear', emoji: '👂', keywords: ['listen', 'hear'] },
{ shortcode: 'nose', emoji: '👃', keywords: ['smell', 'sniff'] },
{ shortcode: 'brain', emoji: '🧠', keywords: ['think', 'smart'] },
{ shortcode: 'eyes', emoji: '👀', keywords: ['look', 'see', 'watch'] },
{ shortcode: 'eye', emoji: '👁️', keywords: ['look', 'see'] },
{ shortcode: 'tongue', emoji: '👅', keywords: ['taste', 'lick'] },
{ shortcode: 'lips', emoji: '👄', keywords: ['mouth', 'kiss'] },
{ shortcode: 'baby', emoji: '👶', keywords: ['child', 'infant'] },
{ shortcode: 'person', emoji: '🧑', keywords: ['human', 'adult'] },
{ shortcode: 'man', emoji: '👨', keywords: ['male', 'guy'] },
{ shortcode: 'woman', emoji: '👩', keywords: ['female', 'lady'] },
{ shortcode: 'older_person', emoji: '🧓', keywords: ['senior', 'elderly'] },
// Hearts & Love
{ shortcode: 'heart', emoji: '❤️', keywords: ['love', 'red'] },
{ shortcode: 'orange_heart', emoji: '🧡', keywords: ['love'] },
{ shortcode: 'yellow_heart', emoji: '💛', keywords: ['love'] },
{ shortcode: 'green_heart', emoji: '💚', keywords: ['love'] },
{ shortcode: 'blue_heart', emoji: '💙', keywords: ['love'] },
{ shortcode: 'purple_heart', emoji: '💜', keywords: ['love'] },
{ shortcode: 'black_heart', emoji: '🖤', keywords: ['love', 'dark'] },
{ shortcode: 'white_heart', emoji: '🤍', keywords: ['love', 'pure'] },
{ shortcode: 'brown_heart', emoji: '🤎', keywords: ['love'] },
{ shortcode: 'broken_heart', emoji: '💔', keywords: ['sad', 'heartbreak'] },
{ shortcode: 'heartbeat', emoji: '💓', keywords: ['love', 'pulse'] },
{ shortcode: 'heartpulse', emoji: '💗', keywords: ['love', 'growing'] },
{ shortcode: 'two_hearts', emoji: '💕', keywords: ['love', 'romance'] },
{ shortcode: 'revolving_hearts', emoji: '💞', keywords: ['love'] },
{ shortcode: 'cupid', emoji: '💘', keywords: ['love', 'arrow'] },
{ shortcode: 'sparkling_heart', emoji: '💖', keywords: ['love', 'sparkle'] },
{ shortcode: 'gift_heart', emoji: '💝', keywords: ['love', 'valentine'] },
{ shortcode: 'heart_decoration', emoji: '💟', keywords: ['love'] },
{ shortcode: 'kiss', emoji: '💋', keywords: ['love', 'lips'] },
{ shortcode: 'love_letter', emoji: '💌', keywords: ['email', 'message'] },
// Symbols & Objects
{ shortcode: 'fire', emoji: '🔥', keywords: ['hot', 'lit', 'flame'] },
{ shortcode: 'star', emoji: '⭐', keywords: ['favorite', 'rating'] },
{ shortcode: 'sparkles', emoji: '✨', keywords: ['shiny', 'new', 'magic'] },
{ shortcode: 'zap', emoji: '⚡', keywords: ['lightning', 'power'] },
{ shortcode: 'boom', emoji: '💥', keywords: ['explosion', 'collision'] },
{ shortcode: 'dizzy', emoji: '💫', keywords: ['star', 'dazed'] },
{ shortcode: 'speech_balloon', emoji: '💬', keywords: ['talk', 'chat'] },
{ shortcode: 'thought_balloon', emoji: '💭', keywords: ['think', 'idea'] },
{ shortcode: 'zzz', emoji: '💤', keywords: ['sleep', 'tired'] },
{ shortcode: 'wave_emoji', emoji: '🌊', keywords: ['ocean', 'water'] },
{ shortcode: 'droplet', emoji: '💧', keywords: ['water', 'sweat'] },
{ shortcode: 'sweat_drops', emoji: '💦', keywords: ['water', 'splash'] },
{ shortcode: 'dash', emoji: '💨', keywords: ['wind', 'running'] },
{ shortcode: 'hole', emoji: '🕳️', keywords: ['empty', 'void'] },
{ shortcode: 'bomb', emoji: '💣', keywords: ['explosive', 'danger'] },
{ shortcode: 'money', emoji: '💰', keywords: ['bag', 'cash', 'dollar'] },
{ shortcode: 'dollar', emoji: '💵', keywords: ['money', 'cash'] },
{ shortcode: 'gem', emoji: '💎', keywords: ['diamond', 'jewel'] },
{ shortcode: 'bulb', emoji: '💡', keywords: ['idea', 'light'] },
{ shortcode: 'bell', emoji: '🔔', keywords: ['notification', 'alert'] },
{ shortcode: 'loudspeaker', emoji: '📢', keywords: ['announce'] },
{ shortcode: 'mega', emoji: '📣', keywords: ['megaphone', 'announce'] },
{ shortcode: 'lock', emoji: '🔒', keywords: ['secure', 'closed'] },
{ shortcode: 'unlock', emoji: '🔓', keywords: ['open', 'access'] },
{ shortcode: 'key', emoji: '🔑', keywords: ['password', 'access'] },
{ shortcode: 'magnifying_glass', emoji: '🔍', keywords: ['search', 'find'] },
{ shortcode: 'link', emoji: '🔗', keywords: ['chain', 'url'] },
{ shortcode: 'paperclip', emoji: '📎', keywords: ['attach'] },
{ shortcode: 'scissors', emoji: '✂️', keywords: ['cut', 'snip'] },
{ shortcode: 'hammer', emoji: '🔨', keywords: ['tool', 'build'] },
{ shortcode: 'wrench', emoji: '🔧', keywords: ['tool', 'fix'] },
{ shortcode: 'gear', emoji: '⚙️', keywords: ['settings', 'cog'] },
{ shortcode: 'shield', emoji: '🛡️', keywords: ['protect', 'security'] },
{ shortcode: 'trophy', emoji: '🏆', keywords: ['win', 'first', 'award'] },
{ shortcode: 'medal', emoji: '🏅', keywords: ['award', 'sports'] },
{ shortcode: 'first_place', emoji: '🥇', keywords: ['gold', 'winner'] },
{ shortcode: 'second_place', emoji: '🥈', keywords: ['silver'] },
{ shortcode: 'third_place', emoji: '🥉', keywords: ['bronze'] },
{ shortcode: 'soccer', emoji: '⚽', keywords: ['football', 'sports'] },
{ shortcode: 'basketball', emoji: '🏀', keywords: ['sports', 'ball'] },
{ shortcode: 'football', emoji: '🏈', keywords: ['sports', 'american'] },
{ shortcode: 'baseball', emoji: '⚾', keywords: ['sports', 'ball'] },
{ shortcode: 'tennis', emoji: '🎾', keywords: ['sports', 'ball'] },
{ shortcode: 'dart', emoji: '🎯', keywords: ['target', 'bullseye'] },
{ shortcode: 'video_game', emoji: '🎮', keywords: ['gaming', 'controller'] },
{ shortcode: 'slot_machine', emoji: '🎰', keywords: ['gambling', 'casino'] },
{ shortcode: 'game_die', emoji: '🎲', keywords: ['dice', 'random'] },
{ shortcode: 'jigsaw', emoji: '🧩', keywords: ['puzzle', 'piece'] },
{ shortcode: 'art', emoji: '🎨', keywords: ['palette', 'paint'] },
{ shortcode: 'performing_arts', emoji: '🎭', keywords: ['theater', 'drama'] },
{ shortcode: 'microphone', emoji: '🎤', keywords: ['sing', 'karaoke'] },
{ shortcode: 'headphones', emoji: '🎧', keywords: ['music', 'audio'] },
{ shortcode: 'musical_note', emoji: '🎵', keywords: ['music', 'song'] },
{ shortcode: 'notes', emoji: '🎶', keywords: ['music', 'melody'] },
{ shortcode: 'guitar', emoji: '🎸', keywords: ['music', 'rock'] },
{ shortcode: 'piano', emoji: '🎹', keywords: ['music', 'keys'] },
{ shortcode: 'drum', emoji: '🥁', keywords: ['music', 'beat'] },
{ shortcode: 'trumpet', emoji: '🎺', keywords: ['music', 'brass'] },
{ shortcode: 'violin', emoji: '🎻', keywords: ['music', 'string'] },
{ shortcode: 'movie_camera', emoji: '🎥', keywords: ['film', 'video'] },
{ shortcode: 'camera', emoji: '📷', keywords: ['photo', 'picture'] },
{ shortcode: 'tv', emoji: '📺', keywords: ['television', 'watch'] },
{ shortcode: 'computer', emoji: '💻', keywords: ['laptop', 'pc'] },
{ shortcode: 'keyboard', emoji: '⌨️', keywords: ['type', 'computer'] },
{ shortcode: 'phone', emoji: '📱', keywords: ['mobile', 'cell'] },
{ shortcode: 'email', emoji: '📧', keywords: ['mail', 'message'] },
{ shortcode: 'inbox', emoji: '📥', keywords: ['mail', 'receive'] },
{ shortcode: 'outbox', emoji: '📤', keywords: ['mail', 'send'] },
{ shortcode: 'package', emoji: '📦', keywords: ['box', 'delivery'] },
{ shortcode: 'memo', emoji: '📝', keywords: ['note', 'write'] },
{ shortcode: 'page', emoji: '📄', keywords: ['document', 'file'] },
{ shortcode: 'bookmark', emoji: '🔖', keywords: ['save', 'tag'] },
{ shortcode: 'book', emoji: '📖', keywords: ['read', 'open'] },
{ shortcode: 'books', emoji: '📚', keywords: ['library', 'study'] },
{ shortcode: 'newspaper', emoji: '📰', keywords: ['news', 'article'] },
{ shortcode: 'calendar', emoji: '📅', keywords: ['date', 'schedule'] },
{ shortcode: 'chart', emoji: '📈', keywords: ['graph', 'increase'] },
{ shortcode: 'chart_down', emoji: '📉', keywords: ['graph', 'decrease'] },
{ shortcode: 'bar_chart', emoji: '📊', keywords: ['graph', 'stats'] },
{ shortcode: 'clipboard', emoji: '📋', keywords: ['list', 'todo'] },
{ shortcode: 'pushpin', emoji: '📌', keywords: ['pin', 'location'] },
{ shortcode: 'round_pushpin', emoji: '📍', keywords: ['pin', 'location'] },
{ shortcode: 'triangular_ruler', emoji: '📐', keywords: ['math', 'measure'] },
{ shortcode: 'straight_ruler', emoji: '📏', keywords: ['math', 'measure'] },
{ shortcode: 'pencil', emoji: '✏️', keywords: ['write', 'draw'] },
{ shortcode: 'pen', emoji: '🖊️', keywords: ['write', 'sign'] },
{ shortcode: 'crayon', emoji: '🖍️', keywords: ['draw', 'color'] },
{ shortcode: 'paintbrush', emoji: '🖌️', keywords: ['art', 'paint'] },
{ shortcode: 'folder', emoji: '📁', keywords: ['file', 'directory'] },
{ shortcode: 'open_folder', emoji: '📂', keywords: ['file', 'directory'] },
// Nature & Animals
{ shortcode: 'dog', emoji: '🐶', keywords: ['puppy', 'pet', 'woof'] },
{ shortcode: 'cat_face', emoji: '🐱', keywords: ['kitty', 'pet', 'meow'] },
{ shortcode: 'mouse', emoji: '🐭', keywords: ['rodent'] },
{ shortcode: 'hamster', emoji: '🐹', keywords: ['pet', 'rodent'] },
{ shortcode: 'rabbit', emoji: '🐰', keywords: ['bunny', 'pet'] },
{ shortcode: 'fox', emoji: '🦊', keywords: ['animal'] },
{ shortcode: 'bear', emoji: '🐻', keywords: ['animal'] },
{ shortcode: 'panda', emoji: '🐼', keywords: ['animal', 'cute'] },
{ shortcode: 'koala', emoji: '🐨', keywords: ['animal', 'australia'] },
{ shortcode: 'tiger', emoji: '🐯', keywords: ['animal', 'cat'] },
{ shortcode: 'lion', emoji: '🦁', keywords: ['animal', 'king'] },
{ shortcode: 'cow', emoji: '🐮', keywords: ['animal', 'farm'] },
{ shortcode: 'pig', emoji: '🐷', keywords: ['animal', 'farm'] },
{ shortcode: 'frog', emoji: '🐸', keywords: ['animal', 'toad'] },
{ shortcode: 'monkey_face', emoji: '🐵', keywords: ['animal', 'ape'] },
{ shortcode: 'chicken', emoji: '🐔', keywords: ['animal', 'farm', 'hen'] },
{ shortcode: 'penguin', emoji: '🐧', keywords: ['animal', 'bird'] },
{ shortcode: 'bird', emoji: '🐦', keywords: ['animal', 'fly'] },
{ shortcode: 'eagle', emoji: '🦅', keywords: ['animal', 'bird'] },
{ shortcode: 'duck', emoji: '🦆', keywords: ['animal', 'bird', 'quack'] },
{ shortcode: 'owl', emoji: '🦉', keywords: ['animal', 'bird', 'night'] },
{ shortcode: 'bat', emoji: '🦇', keywords: ['animal', 'night', 'vampire'] },
{ shortcode: 'wolf', emoji: '🐺', keywords: ['animal'] },
{ shortcode: 'horse', emoji: '🐴', keywords: ['animal'] },
{ shortcode: 'unicorn', emoji: '🦄', keywords: ['animal', 'magic'] },
{ shortcode: 'bee', emoji: '🐝', keywords: ['insect', 'honey'] },
{ shortcode: 'bug', emoji: '🐛', keywords: ['insect', 'caterpillar'] },
{ shortcode: 'butterfly', emoji: '🦋', keywords: ['insect', 'pretty'] },
{ shortcode: 'snail', emoji: '🐌', keywords: ['slow'] },
{ shortcode: 'lady_beetle', emoji: '🐞', keywords: ['insect', 'bug'] },
{ shortcode: 'ant', emoji: '🐜', keywords: ['insect', 'bug'] },
{ shortcode: 'spider', emoji: '🕷️', keywords: ['insect', 'scary'] },
{ shortcode: 'turtle', emoji: '🐢', keywords: ['animal', 'slow'] },
{ shortcode: 'snake', emoji: '🐍', keywords: ['animal', 'reptile'] },
{ shortcode: 'dragon', emoji: '🐲', keywords: ['animal', 'mythical'] },
{ shortcode: 'dinosaur', emoji: '🦕', keywords: ['animal', 'extinct'] },
{ shortcode: 't_rex', emoji: '🦖', keywords: ['animal', 'dinosaur'] },
{ shortcode: 'whale', emoji: '🐳', keywords: ['animal', 'ocean'] },
{ shortcode: 'dolphin', emoji: '🐬', keywords: ['animal', 'ocean'] },
{ shortcode: 'fish', emoji: '🐟', keywords: ['animal', 'ocean'] },
{ shortcode: 'tropical_fish', emoji: '🐠', keywords: ['animal', 'ocean'] },
{ shortcode: 'shark', emoji: '🦈', keywords: ['animal', 'ocean'] },
{ shortcode: 'octopus', emoji: '🐙', keywords: ['animal', 'ocean'] },
{ shortcode: 'crab', emoji: '🦀', keywords: ['animal', 'ocean'] },
{ shortcode: 'lobster', emoji: '🦞', keywords: ['animal', 'ocean'] },
{ shortcode: 'shrimp', emoji: '🦐', keywords: ['animal', 'ocean'] },
// Plants & Nature
{ shortcode: 'bouquet', emoji: '💐', keywords: ['flowers', 'gift'] },
{ shortcode: 'cherry_blossom', emoji: '🌸', keywords: ['flower', 'spring'] },
{ shortcode: 'rose', emoji: '🌹', keywords: ['flower', 'love'] },
{ shortcode: 'tulip', emoji: '🌷', keywords: ['flower', 'spring'] },
{ shortcode: 'sunflower', emoji: '🌻', keywords: ['flower', 'summer'] },
{ shortcode: 'hibiscus', emoji: '🌺', keywords: ['flower', 'tropical'] },
{ shortcode: 'seedling', emoji: '🌱', keywords: ['plant', 'grow'] },
{ shortcode: 'evergreen_tree', emoji: '🌲', keywords: ['tree', 'pine'] },
{ shortcode: 'deciduous_tree', emoji: '🌳', keywords: ['tree'] },
{ shortcode: 'palm_tree', emoji: '🌴', keywords: ['tree', 'tropical'] },
{ shortcode: 'cactus', emoji: '🌵', keywords: ['plant', 'desert'] },
{ shortcode: 'herb', emoji: '🌿', keywords: ['plant', 'leaf'] },
{ shortcode: 'shamrock', emoji: '☘️', keywords: ['clover', 'irish'] },
{ shortcode: 'four_leaf_clover', emoji: '🍀', keywords: ['luck', 'irish'] },
{ shortcode: 'maple_leaf', emoji: '🍁', keywords: ['fall', 'autumn'] },
{ shortcode: 'fallen_leaf', emoji: '🍂', keywords: ['fall', 'autumn'] },
{ shortcode: 'leaves', emoji: '🍃', keywords: ['leaf', 'wind'] },
{ shortcode: 'mushroom', emoji: '🍄', keywords: ['fungus'] },
// Food & Drink
{ shortcode: 'apple', emoji: '🍎', keywords: ['fruit', 'red'] },
{ shortcode: 'green_apple', emoji: '🍏', keywords: ['fruit'] },
{ shortcode: 'pear', emoji: '🍐', keywords: ['fruit'] },
{ shortcode: 'orange', emoji: '🍊', keywords: ['fruit', 'citrus'] },
{ shortcode: 'lemon', emoji: '🍋', keywords: ['fruit', 'citrus'] },
{ shortcode: 'banana', emoji: '🍌', keywords: ['fruit'] },
{ shortcode: 'watermelon', emoji: '🍉', keywords: ['fruit', 'summer'] },
{ shortcode: 'grapes', emoji: '🍇', keywords: ['fruit', 'wine'] },
{ shortcode: 'strawberry', emoji: '🍓', keywords: ['fruit', 'berry'] },
{ shortcode: 'cherries', emoji: '🍒', keywords: ['fruit'] },
{ shortcode: 'peach', emoji: '🍑', keywords: ['fruit'] },
{ shortcode: 'mango', emoji: '🥭', keywords: ['fruit', 'tropical'] },
{ shortcode: 'pineapple', emoji: '🍍', keywords: ['fruit', 'tropical'] },
{ shortcode: 'coconut', emoji: '🥥', keywords: ['fruit', 'tropical'] },
{ shortcode: 'avocado', emoji: '🥑', keywords: ['fruit', 'guacamole'] },
{ shortcode: 'tomato', emoji: '🍅', keywords: ['vegetable', 'red'] },
{ shortcode: 'eggplant', emoji: '🍆', keywords: ['vegetable', 'purple'] },
{ shortcode: 'potato', emoji: '🥔', keywords: ['vegetable', 'spud'] },
{ shortcode: 'carrot', emoji: '🥕', keywords: ['vegetable', 'orange'] },
{ shortcode: 'corn', emoji: '🌽', keywords: ['vegetable', 'maize'] },
{ shortcode: 'hot_pepper', emoji: '🌶️', keywords: ['spicy', 'chili'] },
{ shortcode: 'broccoli', emoji: '🥦', keywords: ['vegetable', 'green'] },
{ shortcode: 'bread', emoji: '🍞', keywords: ['food', 'toast'] },
{ shortcode: 'croissant', emoji: '🥐', keywords: ['food', 'french'] },
{ shortcode: 'pretzel', emoji: '🥨', keywords: ['food', 'snack'] },
{ shortcode: 'bagel', emoji: '🥯', keywords: ['food', 'breakfast'] },
{ shortcode: 'cheese', emoji: '🧀', keywords: ['food', 'dairy'] },
{ shortcode: 'egg', emoji: '🥚', keywords: ['food', 'breakfast'] },
{ shortcode: 'bacon', emoji: '🥓', keywords: ['food', 'breakfast'] },
{ shortcode: 'pancakes', emoji: '🥞', keywords: ['food', 'breakfast'] },
{ shortcode: 'waffle', emoji: '🧇', keywords: ['food', 'breakfast'] },
{ shortcode: 'steak', emoji: '🥩', keywords: ['food', 'meat'] },
{ shortcode: 'poultry_leg', emoji: '🍗', keywords: ['food', 'chicken'] },
{ shortcode: 'hamburger', emoji: '🍔', keywords: ['food', 'burger'] },
{ shortcode: 'fries', emoji: '🍟', keywords: ['food', 'fast'] },
{ shortcode: 'pizza', emoji: '🍕', keywords: ['food', 'italian'] },
{ shortcode: 'hot_dog', emoji: '🌭', keywords: ['food', 'fast'] },
{ shortcode: 'sandwich', emoji: '🥪', keywords: ['food', 'lunch'] },
{ shortcode: 'taco', emoji: '🌮', keywords: ['food', 'mexican'] },
{ shortcode: 'burrito', emoji: '🌯', keywords: ['food', 'mexican'] },
{ shortcode: 'sushi', emoji: '🍣', keywords: ['food', 'japanese'] },
{ shortcode: 'ramen', emoji: '🍜', keywords: ['food', 'noodles'] },
{ shortcode: 'spaghetti', emoji: '🍝', keywords: ['food', 'pasta'] },
{ shortcode: 'curry', emoji: '🍛', keywords: ['food', 'rice'] },
{ shortcode: 'rice', emoji: '🍚', keywords: ['food', 'white'] },
{ shortcode: 'salad', emoji: '🥗', keywords: ['food', 'healthy'] },
{ shortcode: 'popcorn', emoji: '🍿', keywords: ['food', 'movie'] },
{ shortcode: 'cake', emoji: '🎂', keywords: ['food', 'birthday'] },
{ shortcode: 'cupcake', emoji: '🧁', keywords: ['food', 'sweet'] },
{ shortcode: 'pie', emoji: '🥧', keywords: ['food', 'dessert'] },
{ shortcode: 'cookie', emoji: '🍪', keywords: ['food', 'sweet'] },
{ shortcode: 'chocolate', emoji: '🍫', keywords: ['food', 'sweet'] },
{ shortcode: 'candy', emoji: '🍬', keywords: ['food', 'sweet'] },
{ shortcode: 'lollipop', emoji: '🍭', keywords: ['food', 'sweet'] },
{ shortcode: 'donut', emoji: '🍩', keywords: ['food', 'sweet'] },
{ shortcode: 'ice_cream', emoji: '🍨', keywords: ['food', 'dessert'] },
{ shortcode: 'icecream', emoji: '🍦', keywords: ['food', 'dessert', 'cone'] },
{ shortcode: 'coffee', emoji: '☕', keywords: ['drink', 'caffeine'] },
{ shortcode: 'tea', emoji: '🍵', keywords: ['drink', 'green'] },
{ shortcode: 'beer', emoji: '🍺', keywords: ['drink', 'alcohol'] },
{ shortcode: 'beers', emoji: '🍻', keywords: ['drink', 'cheers'] },
{ shortcode: 'wine_glass', emoji: '🍷', keywords: ['drink', 'alcohol'] },
{ shortcode: 'cocktail', emoji: '🍸', keywords: ['drink', 'alcohol'] },
{ shortcode: 'tropical_drink', emoji: '🍹', keywords: ['drink', 'vacation'] },
{ shortcode: 'champagne', emoji: '🍾', keywords: ['drink', 'celebrate'] },
{ shortcode: 'milk', emoji: '🥛', keywords: ['drink', 'dairy'] },
{ shortcode: 'baby_bottle', emoji: '🍼', keywords: ['drink', 'infant'] },
{ shortcode: 'juice', emoji: '🧃', keywords: ['drink', 'box'] },
{ shortcode: 'cup_with_straw', emoji: '🥤', keywords: ['drink', 'soda'] },
// Weather & Nature
{ shortcode: 'sun', emoji: '☀️', keywords: ['weather', 'sunny', 'bright'] },
{ shortcode: 'moon', emoji: '🌙', keywords: ['night', 'sleep'] },
{ shortcode: 'full_moon', emoji: '🌕', keywords: ['night', 'lunar'] },
{ shortcode: 'new_moon', emoji: '🌑', keywords: ['night', 'dark'] },
{ shortcode: 'star2', emoji: '🌟', keywords: ['glow', 'sparkle'] },
{ shortcode: 'milky_way', emoji: '🌌', keywords: ['galaxy', 'space'] },
{ shortcode: 'cloud', emoji: '☁️', keywords: ['weather', 'sky'] },
{ shortcode: 'sun_behind_cloud', emoji: '⛅', keywords: ['weather'] },
{ shortcode: 'cloud_with_rain', emoji: '🌧️', keywords: ['weather', 'rainy'] },
{ shortcode: 'thunder', emoji: '⛈️', keywords: ['weather', 'storm'] },
{ shortcode: 'snowflake', emoji: '❄️', keywords: ['weather', 'cold'] },
{ shortcode: 'snowman', emoji: '☃️', keywords: ['winter', 'snow'] },
{ shortcode: 'wind_blowing', emoji: '🌬️', keywords: ['weather', 'air'] },
{ shortcode: 'tornado', emoji: '🌪️', keywords: ['weather', 'storm'] },
{ shortcode: 'fog', emoji: '🌫️', keywords: ['weather', 'mist'] },
{ shortcode: 'umbrella', emoji: '☂️', keywords: ['rain', 'weather'] },
{ shortcode: 'rainbow', emoji: '🌈', keywords: ['weather', 'pride'] },
{ shortcode: 'earth', emoji: '🌍', keywords: ['world', 'planet'] },
{ shortcode: 'earth_americas', emoji: '🌎', keywords: ['world', 'planet'] },
{ shortcode: 'earth_asia', emoji: '🌏', keywords: ['world', 'planet'] },
{ shortcode: 'rocket', emoji: '🚀', keywords: ['space', 'launch'] },
{ shortcode: 'satellite', emoji: '🛰️', keywords: ['space', 'orbit'] },
{ shortcode: 'ufo', emoji: '🛸', keywords: ['alien', 'space'] },
// Checkmarks & Common Symbols
{ shortcode: 'white_check_mark', emoji: '✅', keywords: ['done', 'yes', 'ok'] },
{ shortcode: 'check', emoji: '✔️', keywords: ['done', 'yes'] },
{ shortcode: 'x', emoji: '❌', keywords: ['no', 'wrong', 'cancel'] },
{ shortcode: 'cross_mark', emoji: '❎', keywords: ['no', 'wrong'] },
{ shortcode: 'plus', emoji: '', keywords: ['add', 'math'] },
{ shortcode: 'minus', emoji: '', keywords: ['subtract', 'math'] },
{ shortcode: 'divide', emoji: '➗', keywords: ['math', 'division'] },
{ shortcode: 'multiply', emoji: '✖️', keywords: ['math', 'times'] },
{ shortcode: 'infinity', emoji: '♾️', keywords: ['forever', 'endless'] },
{ shortcode: 'question', emoji: '❓', keywords: ['ask', 'what'] },
{ shortcode: 'grey_question', emoji: '❔', keywords: ['ask', 'what'] },
{ shortcode: 'exclamation', emoji: '❗', keywords: ['alert', 'important'] },
{ shortcode: 'grey_exclamation', emoji: '❕', keywords: ['alert'] },
{ shortcode: 'warning', emoji: '⚠️', keywords: ['alert', 'caution'] },
{ shortcode: 'no_entry', emoji: '⛔', keywords: ['stop', 'forbidden'] },
{ shortcode: 'prohibited', emoji: '🚫', keywords: ['stop', 'banned'] },
{ shortcode: 'recycle', emoji: '♻️', keywords: ['environment', 'green'] },
{ shortcode: 'arrow_up', emoji: '⬆️', keywords: ['direction', 'north'] },
{ shortcode: 'arrow_down', emoji: '⬇️', keywords: ['direction', 'south'] },
{ shortcode: 'arrow_left', emoji: '⬅️', keywords: ['direction', 'west'] },
{ shortcode: 'arrow_right', emoji: '➡️', keywords: ['direction', 'east'] },
{
shortcode: 'arrow_upper_right',
emoji: '↗️',
keywords: ['direction', 'northeast'],
},
{
shortcode: 'arrow_lower_right',
emoji: '↘️',
keywords: ['direction', 'southeast'],
},
{
shortcode: 'arrow_lower_left',
emoji: '↙️',
keywords: ['direction', 'southwest'],
},
{
shortcode: 'arrow_upper_left',
emoji: '↖️',
keywords: ['direction', 'northwest'],
},
{
shortcode: 'left_right_arrow',
emoji: '↔️',
keywords: ['direction', 'horizontal'],
},
{
shortcode: 'up_down_arrow',
emoji: '↕️',
keywords: ['direction', 'vertical'],
},
{ shortcode: 'arrows_clockwise', emoji: '🔃', keywords: ['refresh', 'sync'] },
{
shortcode: 'arrows_counterclockwise',
emoji: '🔄',
keywords: ['refresh', 'sync'],
},
{ shortcode: 'back', emoji: '🔙', keywords: ['return', 'previous'] },
{ shortcode: 'end', emoji: '🔚', keywords: ['finish', 'last'] },
{ shortcode: 'on', emoji: '🔛', keywords: ['active'] },
{ shortcode: 'soon', emoji: '🔜', keywords: ['coming', 'future'] },
{ shortcode: 'top', emoji: '🔝', keywords: ['best', 'first'] },
{ shortcode: 'new', emoji: '🆕', keywords: ['fresh', 'latest'] },
{ shortcode: 'free', emoji: '🆓', keywords: ['gratis', 'cost'] },
{ shortcode: 'up', emoji: '🆙', keywords: ['increase', 'level'] },
{ shortcode: 'cool', emoji: '🆒', keywords: ['nice', 'awesome'] },
{ shortcode: 'ok', emoji: '🆗', keywords: ['yes', 'approve'] },
{ shortcode: 'sos', emoji: '🆘', keywords: ['help', 'emergency'] },
{ shortcode: 'stop_sign', emoji: '🛑', keywords: ['halt', 'cease'] },
{ shortcode: 'a', emoji: '🅰️', keywords: ['letter', 'blood'] },
{ shortcode: 'b', emoji: '🅱️', keywords: ['letter', 'blood'] },
{ shortcode: 'o', emoji: '🅾️', keywords: ['letter', 'blood'] },
{ shortcode: 'information', emoji: '', keywords: ['info', 'help'] },
{ shortcode: 'copyright', emoji: '©️', keywords: ['legal', 'ip'] },
{ shortcode: 'registered', emoji: '®️', keywords: ['legal', 'brand'] },
{ shortcode: 'tm', emoji: '™️', keywords: ['legal', 'trademark'] },
{ shortcode: 'one', emoji: '1⃣', keywords: ['number', 'first'] },
{ shortcode: 'two', emoji: '2⃣', keywords: ['number', 'second'] },
{ shortcode: 'three', emoji: '3⃣', keywords: ['number', 'third'] },
{ shortcode: 'four', emoji: '4⃣', keywords: ['number'] },
{ shortcode: 'five', emoji: '5⃣', keywords: ['number'] },
{ shortcode: 'six', emoji: '6⃣', keywords: ['number'] },
{ shortcode: 'seven', emoji: '7⃣', keywords: ['number'] },
{ shortcode: 'eight', emoji: '8⃣', keywords: ['number'] },
{ shortcode: 'nine', emoji: '9⃣', keywords: ['number'] },
{ shortcode: 'zero', emoji: '0⃣', keywords: ['number'] },
{ shortcode: 'keycap_ten', emoji: '🔟', keywords: ['number', 'ten'] },
{ shortcode: 'hash', emoji: '#️⃣', keywords: ['number', 'pound', 'hashtag'] },
{ shortcode: 'asterisk', emoji: '*️⃣', keywords: ['star', 'symbol'] },
{ shortcode: 'eject', emoji: '⏏️', keywords: ['media', 'remove'] },
{ shortcode: 'play', emoji: '▶️', keywords: ['media', 'start'] },
{ shortcode: 'pause', emoji: '⏸️', keywords: ['media', 'wait'] },
{ shortcode: 'stop', emoji: '⏹️', keywords: ['media', 'end'] },
{ shortcode: 'record', emoji: '⏺️', keywords: ['media', 'red'] },
{ shortcode: 'fast_forward', emoji: '⏩', keywords: ['media', 'skip'] },
{ shortcode: 'rewind', emoji: '⏪', keywords: ['media', 'back'] },
{ shortcode: 'next_track', emoji: '⏭️', keywords: ['media', 'skip'] },
{ shortcode: 'previous_track', emoji: '⏮️', keywords: ['media', 'back'] },
{ shortcode: 'cinema', emoji: '🎦', keywords: ['movie', 'film'] },
{ shortcode: 'low_brightness', emoji: '🔅', keywords: ['dim', 'light'] },
{ shortcode: 'high_brightness', emoji: '🔆', keywords: ['bright', 'light'] },
{ shortcode: 'signal_strength', emoji: '📶', keywords: ['wifi', 'bars'] },
{ shortcode: 'vibration', emoji: '📳', keywords: ['phone', 'mode'] },
{ shortcode: 'mobile_off', emoji: '📴', keywords: ['phone', 'silent'] },
{ shortcode: 'female', emoji: '♀️', keywords: ['woman', 'gender'] },
{ shortcode: 'male', emoji: '♂️', keywords: ['man', 'gender'] },
{ shortcode: 'medical', emoji: '⚕️', keywords: ['health', 'doctor'] },
{ shortcode: 'atom', emoji: '⚛️', keywords: ['science', 'physics'] },
];
/**
* Filter emojis by search text (checks shortcode and keywords)
*/
export function filterEmojis(
searchText: string,
limit: number = 10,
): EmojiItem[] {
if (!searchText) return [];
const lowerSearch = searchText.toLowerCase();
return EMOJI_DATA.filter(
item =>
item.shortcode.toLowerCase().includes(lowerSearch) ||
item.keywords?.some(keyword =>
keyword.toLowerCase().includes(lowerSearch),
),
).slice(0, limit);
}

View File

@@ -0,0 +1,247 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef, useCallback, useMemo, useState, useRef } from 'react';
import { Mentions } from 'antd';
import type { MentionsRef, MentionsProps } from 'antd/es/mentions';
import { filterEmojis, type EmojiItem } from './emojiData';
const MIN_CHARS_BEFORE_POPUP = 2;
// Regex to match emoji characters (simplified, covers most common emojis)
const EMOJI_REGEX =
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
export interface EmojiTextAreaProps
extends Omit<MentionsProps, 'prefix' | 'options' | 'onSelect'> {
/**
* Minimum characters after colon before showing popup.
* @default 2 (Slack-like behavior)
*/
minCharsBeforePopup?: number;
/**
* Maximum number of emoji suggestions to show.
* @default 10
*/
maxSuggestions?: number;
/**
* Called when an emoji is selected from the popup.
*/
onEmojiSelect?: (emoji: EmojiItem) => void;
}
/**
* A TextArea component with Slack-like emoji autocomplete.
*
* Features:
* - Triggers on `:` prefix (like Slack)
* - Only shows popup after 2+ characters are typed (configurable)
* - Colon must be preceded by a space, start of line, or another emoji
* - Prevents accidental Enter key selection when typing quickly
*
* @example
* ```tsx
* <EmojiTextArea
* placeholder="Type :sm to see emoji suggestions..."
* onChange={(text) => console.log(text)}
* />
* ```
*/
export const EmojiTextArea = forwardRef<MentionsRef, EmojiTextAreaProps>(
(
{
minCharsBeforePopup = MIN_CHARS_BEFORE_POPUP,
maxSuggestions = 10,
onEmojiSelect,
onChange,
onKeyDown,
...restProps
},
ref,
) => {
const [options, setOptions] = useState<
Array<{ value: string; label: React.ReactNode }>
>([]);
const [isPopupVisible, setIsPopupVisible] = useState(false);
const lastSearchRef = useRef<string>('');
const lastKeyPressTimeRef = useRef<number>(0);
/**
* Validates whether the colon trigger should activate the popup.
* Implements Slack-like behavior:
* - Colon must be preceded by whitespace, start of text, or emoji
* - At least minCharsBeforePopup characters must be typed after colon
*/
const validateSearch = useCallback(
(text: string, props: MentionsProps): boolean => {
// Get the full value to check what precedes the colon
const fullValue = (props.value as string) || '';
// Find where this search text starts in the full value
// The search text is what comes after the `:` prefix
const colonIndex = fullValue.lastIndexOf(`:${text}`);
if (colonIndex === -1) {
setIsPopupVisible(false);
return false;
}
// Check what precedes the colon
if (colonIndex > 0) {
const charBefore = fullValue[colonIndex - 1];
// Must be preceded by whitespace, newline, or emoji
const isWhitespace = /\s/.test(charBefore);
const isEmoji = EMOJI_REGEX.test(charBefore);
if (!isWhitespace && !isEmoji) {
setIsPopupVisible(false);
return false;
}
}
// Check minimum character requirement
if (text.length < minCharsBeforePopup) {
setIsPopupVisible(false);
return false;
}
setIsPopupVisible(true);
return true;
},
[minCharsBeforePopup],
);
/**
* Handles search and filters emoji suggestions.
*/
const handleSearch = useCallback(
(searchText: string) => {
lastSearchRef.current = searchText;
if (searchText.length < minCharsBeforePopup) {
setOptions([]);
return;
}
const filteredEmojis = filterEmojis(searchText, maxSuggestions);
const newOptions = filteredEmojis.map(item => ({
value: item.emoji,
label: (
<span>
<span style={{ marginRight: 8 }}>{item.emoji}</span>
<span style={{ color: 'var(--ant-color-text-secondary)' }}>
:{item.shortcode}:
</span>
</span>
),
// Store the full item for onSelect callback
data: item,
}));
setOptions(newOptions);
},
[minCharsBeforePopup, maxSuggestions],
);
/**
* Handles emoji selection from the popup.
*/
const handleSelect = useCallback(
(option: { value: string; data?: EmojiItem }) => {
if (option.data && onEmojiSelect) {
onEmojiSelect(option.data);
}
setIsPopupVisible(false);
},
[onEmojiSelect],
);
/**
* Handles key down events to prevent accidental selection on Enter.
* If the user presses Enter very quickly after typing (< 100ms),
* we treat it as a newline intent rather than selection.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const now = Date.now();
const timeSinceLastKey = now - lastKeyPressTimeRef.current;
// If Enter is pressed and popup is visible
if (e.key === 'Enter' && isPopupVisible) {
// If typed very quickly (< 100ms since last keypress) and
// there's meaningful search text, allow the Enter to create newline
// This prevents accidental selection when typing something like:
// "let me show you an example:[Enter]"
if (timeSinceLastKey < 100 && lastSearchRef.current.length === 0) {
// Let the default behavior (newline) happen
setIsPopupVisible(false);
return;
}
}
lastKeyPressTimeRef.current = now;
// Call original onKeyDown if provided
onKeyDown?.(e);
},
[isPopupVisible, onKeyDown],
);
const handleChange = useCallback(
(text: string) => {
lastKeyPressTimeRef.current = Date.now();
onChange?.(text);
},
[onChange],
);
// Memoize the Mentions component props
const mentionsProps = useMemo(
() => ({
prefix: ':',
split: '',
options,
validateSearch,
onSearch: handleSearch,
onSelect: handleSelect,
onKeyDown: handleKeyDown,
onChange: handleChange,
notFoundContent: null, // Don't show "Not Found" message
...restProps,
}),
[
options,
validateSearch,
handleSearch,
handleSelect,
handleKeyDown,
handleChange,
restProps,
],
);
return <Mentions ref={ref} {...mentionsProps} />;
},
);
EmojiTextArea.displayName = 'EmojiTextArea';
export type { EmojiItem };
export { filterEmojis, EMOJI_DATA } from './emojiData';

View File

@@ -102,6 +102,13 @@ export {
type DynamicEditableTitleProps,
} from './DynamicEditableTitle';
export { EditableTitle, type EditableTitleProps } from './EditableTitle';
export {
EmojiTextArea,
type EmojiTextAreaProps,
type EmojiItem,
filterEmojis,
EMOJI_DATA,
} from './EmojiTextArea';
export { EmptyState, type EmptyStateProps } from './EmptyState';
export { Empty, type EmptyProps } from './EmptyState/Empty';
export { FaveStar, type FaveStarProps } from './FaveStar';