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 000000000000..ad7a2665af7a --- /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 = { + 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'; + + 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; + +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([]); + + return ( +
+ setSelectedEmojis(prev => [...prev, emoji])} + placeholder="Type :smile: or :heart: to add emojis..." + rows={4} + style={{ width: '100%' }} + /> +
+ Current value: +
+            {value || '(empty)'}
+          
+
+ {selectedEmojis.length > 0 && ( +
+ Selected emojis: +
+ {selectedEmojis.map((e, i) => ( + + {e.emoji} + + ))} +
+
+ )} +
+ ); + }, +}; + +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 ( +
+

Slack-like Trigger Behavior

+

+ The emoji picker mimics Slack's behavior. Try these examples: +

+ + + + + + + + + + + {examples.map((ex, i) => ( + + + + + + ))} + +
+ Input + + Shows Popup? + + Reason +
+ {ex.input} + + {ex.works ? 'โœ… Yes' : 'โŒ No'} + + {ex.desc} +
+ + +
+ ); + }, +}; + +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 ( +
+
+ + setTitle(e.target.value)} + placeholder="Enter a title" + style={{ + width: '100%', + padding: 8, + borderRadius: 4, + border: '1px solid var(--ant-color-border)', + }} + /> +
+ +
+ + +
+ + +
+ ); + }, +}; 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 000000000000..f7c29600a329 --- /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(); + expect(screen.getByPlaceholderText('Type something...')).toBeInTheDocument(); +}); + +test('renders EmojiTextArea as textarea element', () => { + render(); + const textarea = screen.getByPlaceholderText('Type here'); + expect(textarea.tagName.toLowerCase()).toBe('textarea'); +}); + +test('allows typing in the textarea', async () => { + render(); + 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(); + const textarea = screen.getByPlaceholderText('Type here'); + await userEvent.type(textarea, 'Hi'); + expect(onChange).toHaveBeenCalled(); +}); + +test('passes through rows prop', () => { + render(); + const textarea = screen.getByPlaceholderText('Type here'); + expect(textarea).toHaveAttribute('rows', '5'); +}); + +test('forwards ref to underlying component', () => { + const ref = { current: null }; + render(); + expect(ref.current).not.toBeNull(); +}); + +test('renders controlled component with value prop', () => { + render( {}} />); + 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 000000000000..a0048dcfc730 --- /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 000000000000..929fb69bca09 --- /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 { + /** + * 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 + * console.log(text)} + * /> + * ``` + */ +export const EmojiTextArea = forwardRef( + ( + { + 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(''); + const lastKeyPressTimeRef = useRef(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: ( + + {item.emoji} + + :{item.shortcode}: + + + ), + // 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) => { + 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 ; + }, +); + +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 12a0504ce55e..8dfc63f9e6e4 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';