This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch emojis-one-chart in repository https://gitbox.apache.org/repos/asf/superset.git
commit 3b5573b7255c9152d7720602a13f50e9667173c3 Author: Maxime Beauchemin <[email protected]> AuthorDate: Thu Dec 4 03:00:25 2025 +0000 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 :smile: to add emojis..." onChange={(text) => console.log(text)} minCharsBeforePopup={2} /> ``` ๐ค Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --- .../EmojiTextArea/EmojiTextArea.stories.tsx | 331 ++++++++++++ .../EmojiTextArea/EmojiTextArea.test.tsx | 170 ++++++ .../src/components/EmojiTextArea/emojiData.ts | 569 +++++++++++++++++++++ .../src/components/EmojiTextArea/index.tsx | 247 +++++++++ .../superset-ui-core/src/components/index.ts | 7 + 5 files changed, 1324 insertions(+) diff --git a/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.stories.tsx new file mode 100644 index 0000000000..ad7a2665af --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.stories.tsx @@ -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'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> + ); + }, +}; diff --git a/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.test.tsx new file mode 100644 index 0000000000..f7c29600a3 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/EmojiTextArea.test.tsx @@ -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); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/emojiData.ts b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/emojiData.ts new file mode 100644 index 0000000000..a0048dcfc7 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/emojiData.ts @@ -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); +} diff --git a/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/index.tsx new file mode 100644 index 0000000000..929fb69bca --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/EmojiTextArea/index.tsx @@ -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'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 12a0504ce5..8dfc63f9e6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -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';
