├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── add-logo.yml │ ├── bug_report.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── release.yml │ ├── tests.yml │ └── update-demo.yml ├── .gitignore ├── .vscode └── tasks.json ├── CNAME ├── LICENSE ├── README.md ├── bun.lock ├── demo ├── index.html ├── package.json ├── scripts │ └── updateNpmStats.ts ├── src │ ├── App.tsx │ ├── Hero.tsx │ ├── Tabs.tsx │ ├── ThemeContext.tsx │ ├── ThemeToggle.tsx │ ├── components │ │ ├── ClipboardButton.tsx │ │ ├── CodeBlock.tsx │ │ ├── FAQ.tsx │ │ ├── Features.tsx │ │ ├── NpmDownloadsPill.tsx │ │ ├── PackageManagerTabs.tsx │ │ ├── SocialProof.tsx │ │ └── TailwindSetupTabs.tsx │ ├── constants │ │ └── npmStats.ts │ ├── index.css │ ├── main.tsx │ ├── playground │ │ ├── DefaultPickerExample.tsx │ │ ├── LinearPickerExample.tsx │ │ ├── Playground.tsx │ │ ├── SlackPickerExample.tsx │ │ ├── index.ts │ │ └── types.ts │ └── social-proof │ │ ├── june.tsx │ │ └── typefully.tsx ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package.json ├── packages └── emoji-picker │ ├── .eslintrc.json │ ├── .prettierrc │ ├── README.md │ ├── build.ts │ ├── bunfig.toml │ ├── package.json │ ├── react-version-tests │ ├── react-18.2.0 │ │ ├── bun.lock │ │ ├── emojiPicker.test.tsx │ │ └── package.json │ └── react-19.0.0 │ │ ├── bun.lock │ │ ├── emojiPicker.test.tsx │ │ └── package.json │ ├── scripts │ ├── __tests__ │ │ └── generateEmojiColors.test.ts │ ├── generateEmojiColors.html │ ├── generateEmojiColors.ts │ ├── release.ts │ └── test-react-versions.ts │ ├── src │ ├── EmojiPicker │ │ ├── EmojiCategories.tsx │ │ ├── EmojiPicker.tsx │ │ ├── EmojiPickerButton.tsx │ │ ├── EmojiPickerContent.tsx │ │ ├── EmojiPickerContext.tsx │ │ ├── EmojiPickerEmpty.tsx │ │ ├── EmojiPickerGroup.tsx │ │ ├── EmojiPickerInput.tsx │ │ ├── EmojiPickerList.tsx │ │ ├── EmojiPickerListHeader.tsx │ │ ├── EmojiPickerPreview.tsx │ │ ├── EmojiPickerSkinTone.tsx │ │ ├── EmojiSearchResults.tsx │ │ ├── __tests__ │ │ │ ├── EmojiCategories.test.tsx │ │ │ ├── EmojiPickerList.test.tsx │ │ │ ├── EmojiPickerListHeader.test.tsx │ │ │ ├── EmojiSearchResults.test.tsx │ │ │ └── testData.ts │ │ ├── icons │ │ │ ├── ClearIcon.tsx │ │ │ ├── SearchIcon.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── __tests__ │ │ └── utils │ │ │ ├── applySkinTone.test.ts │ │ │ ├── emojiFilters.test.ts │ │ │ └── emojiSearch.test.ts │ ├── atoms │ │ ├── __tests__ │ │ │ └── emoji.test.ts │ │ └── emoji.ts │ ├── hooks │ │ ├── useEmojiKeyboardNavigation.ts │ │ └── useVirtualizedList.ts │ ├── index.ts │ ├── test │ │ ├── happydom.ts │ │ ├── matchers.d.ts │ │ └── setup.ts │ ├── types │ │ └── emoji.ts │ └── utils │ │ ├── applySkinTone.ts │ │ ├── cn.ts │ │ ├── emojiColors.ts │ │ ├── emojiColors.ts.backup │ │ ├── emojiFilters.ts │ │ ├── emojiSearch.ts │ │ └── supportedEmojis.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── public └── emoji-picker-repo-asset.png ├── scripts └── release.ts └── vite.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/add-logo.yml: -------------------------------------------------------------------------------- 1 | name: Add Logo to Social Proof 2 | description: Request to add your company logo to the social proof section 3 | title: "Add Logo: [Company Name]" 4 | labels: ["social-proof", "enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for using emoji-picker! We'd love to showcase your company in our social proof section. 10 | - type: input 11 | id: company 12 | attributes: 13 | label: Company Name 14 | description: What's your company's name? 15 | placeholder: "Example: Acme Inc." 16 | validations: 17 | required: true 18 | - type: input 19 | id: website 20 | attributes: 21 | label: Company Website 22 | description: Your company's website URL 23 | placeholder: "https://example.com" 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: logo 28 | attributes: 29 | label: Logo SVG 30 | description: Please provide your company logo as an SVG component (similar to the June logo in social-proof/june.tsx) 31 | placeholder: | 32 | ```tsx 33 | export const CompanyLogo: React.FC = () => ( 34 | 35 | 36 | 37 | ); 38 | ``` 39 | validations: 40 | required: true 41 | - type: checkboxes 42 | id: terms 43 | attributes: 44 | label: I confirm I have the rights to use and share this logo 45 | options: 46 | - label: I confirm I have the rights to use and share this logo 47 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | title: "Bug: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Describe the bug 14 | description: A clear and concise description of what the bug is 15 | placeholder: "When I try to..." 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: reproduction 20 | attributes: 21 | label: Steps to reproduce 22 | description: How can we reproduce this issue? 23 | placeholder: | 24 | 1. Go to '...' 25 | 2. Click on '...' 26 | 3. See error 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: expected 31 | attributes: 32 | label: Expected behavior 33 | description: What did you expect to happen? 34 | validations: 35 | required: true 36 | - type: input 37 | id: version 38 | attributes: 39 | label: Package Version 40 | description: What version of @ferrucc-io/emoji-picker are you using? 41 | placeholder: "1.0.0" 42 | validations: 43 | required: true 44 | - type: dropdown 45 | id: browsers 46 | attributes: 47 | label: Browser 48 | description: What browsers are you seeing the problem on? 49 | multiple: true 50 | options: 51 | - Chrome 52 | - Firefox 53 | - Safari 54 | - Microsoft Edge 55 | - Arc 56 | - Other 57 | validations: 58 | required: true 59 | - type: textarea 60 | id: additional 61 | attributes: 62 | label: Additional context 63 | description: Add any other context about the problem here (screenshots, error messages, etc.) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "Feature: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to suggest a new feature! 10 | - type: textarea 11 | id: problem 12 | attributes: 13 | label: Is your feature request related to a problem? 14 | description: A clear and concise description of what the problem is 15 | placeholder: "I'm always frustrated when..." 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: solution 20 | attributes: 21 | label: Describe the solution you'd like 22 | description: A clear and concise description of what you want to happen 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: alternatives 27 | attributes: 28 | label: Describe alternatives you've considered 29 | description: A clear and concise description of any alternative solutions or features you've considered 30 | - type: dropdown 31 | id: impact 32 | attributes: 33 | label: Impact 34 | description: How would you rate the impact of this feature? 35 | options: 36 | - Must have 37 | - Nice to have 38 | - Minor enhancement 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: additional 43 | attributes: 44 | label: Additional context 45 | description: Add any other context, screenshots, or examples about the feature request here -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for package.json files in the root directory 6 | directory: "/" 7 | # Check for updates once a week 8 | schedule: 9 | interval: "weekly" 10 | # Group all updates into a single PR 11 | groups: 12 | all: 13 | patterns: ["*"] 14 | 15 | # Enable version updates for demo package 16 | - package-ecosystem: "npm" 17 | directory: "/demo" 18 | schedule: 19 | interval: "weekly" 20 | groups: 21 | all: 22 | patterns: ["*"] 23 | 24 | # Enable version updates for emoji-picker package 25 | - package-ecosystem: "npm" 26 | directory: "/packages/emoji-picker" 27 | schedule: 28 | interval: "weekly" 29 | groups: 30 | all: 31 | patterns: ["*"] 32 | 33 | # Enable version updates for GitHub Actions 34 | - package-ecosystem: "github-actions" 35 | directory: "/" 36 | schedule: 37 | interval: "weekly" 38 | groups: 39 | all: 40 | patterns: ["*"] -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | 9 | permissions: 10 | contents: write 11 | pages: write 12 | id-token: write 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Setup Bun 21 | uses: oven-sh/setup-bun@v2 22 | with: 23 | bun-version: latest 24 | 25 | - name: Install dependencies 26 | run: bun install 27 | 28 | - name: Run tests 29 | run: bun run test 30 | 31 | - name: Build library & demo 32 | run: bun run build 33 | 34 | - name: Create Release 35 | uses: softprops/action-gh-release@v2 36 | with: 37 | generate_release_notes: true 38 | 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: '20.x' 43 | registry-url: 'https://registry.npmjs.org' 44 | 45 | - name: Publish to NPM 46 | run: cd packages/emoji-picker && npm publish --access public 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | 50 | - name: Upload Pages artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: './demo/dist' 54 | 55 | deploy: 56 | needs: build 57 | runs-on: ubuntu-latest 58 | 59 | permissions: 60 | pages: write 61 | id-token: write 62 | 63 | environment: 64 | name: github-pages 65 | url: ${{ steps.deployment.outputs.page_url }} 66 | 67 | steps: 68 | - name: Deploy to GitHub Pages 69 | id: deployment 70 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Bun 17 | uses: oven-sh/setup-bun@v2 18 | with: 19 | bun-version: latest 20 | 21 | - name: Install dependencies 22 | run: bun install 23 | 24 | - name: Check formatting 25 | run: | 26 | bun run format:check 27 | 28 | - name: Run linting 29 | run: bun run lint 30 | 31 | - name: Run tests 32 | run: bun run test 33 | 34 | - name: Build library 35 | run: bun run build 36 | 37 | - name: Build demo 38 | run: bun run build 39 | -------------------------------------------------------------------------------- /.github/workflows/update-demo.yml: -------------------------------------------------------------------------------- 1 | name: Update Demo Site 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 0' # Run at midnight UTC every Sunday 6 | workflow_dispatch: # Allows manual triggering 7 | 8 | permissions: 9 | pages: write 10 | id-token: write 11 | 12 | jobs: 13 | build-and-deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Bun 19 | uses: oven-sh/setup-bun@v2 20 | with: 21 | bun-version: latest 22 | 23 | - name: Install dependencies 24 | run: bun install 25 | 26 | - name: Build demo 27 | run: bun run build 28 | 29 | - name: Upload Pages artifact 30 | uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: './demo/dist' 33 | 34 | - name: Deploy to GitHub Pages 35 | id: deployment 36 | uses: actions/deploy-pages@v4 37 | 38 | environment: 39 | name: github-pages 40 | url: ${{ steps.deployment.outputs.page_url }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | TODO 10 | .cursor/rules 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | .aider* 134 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Test: Run All", 6 | "type": "shell", 7 | "command": "bun test", 8 | "group": { 9 | "kind": "test", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "reveal": "always", 14 | "panel": "dedicated", 15 | "clear": true 16 | }, 17 | "problemMatcher": [] 18 | }, 19 | { 20 | "label": "Test: Watch", 21 | "type": "shell", 22 | "command": "bun test --watch", 23 | "group": "test", 24 | "presentation": { 25 | "reveal": "always", 26 | "panel": "dedicated", 27 | "clear": true 28 | }, 29 | "problemMatcher": [], 30 | "isBackground": true 31 | }, 32 | { 33 | "label": "Test: Coverage", 34 | "type": "shell", 35 | "command": "bun test --coverage", 36 | "group": "test", 37 | "presentation": { 38 | "reveal": "always", 39 | "panel": "dedicated", 40 | "clear": true 41 | }, 42 | "problemMatcher": [] 43 | }, 44 | { 45 | "label": "Test: Current File", 46 | "type": "shell", 47 | "command": "bun test ${relativeFile}", 48 | "group": "test", 49 | "presentation": { 50 | "reveal": "always", 51 | "panel": "dedicated", 52 | "clear": true 53 | }, 54 | "problemMatcher": [] 55 | }, 56 | { 57 | "label": "Release: Patch", 58 | "type": "shell", 59 | "command": "bun run release:patch", 60 | "group": "none", 61 | "problemMatcher": [] 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | emoji.ferrucc.io -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ferruccio Balestreri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Emoji Picker

3 |

A composable React emoji picker component with Tailwind styling

4 |
5 | 6 |
7 | 8 | [![npm version](https://img.shields.io/npm/v/@ferrucc-io/emoji-picker.svg?style=flat)](https://www.npmjs.com/package/@ferrucc-io/emoji-picker) 9 | [![npm downloads](https://img.shields.io/npm/dm/@ferrucc-io/emoji-picker.svg?style=flat)](https://www.npmjs.com/package/@ferrucc-io/emoji-picker) 10 | [![License](https://img.shields.io/npm/l/@ferrucc-io/emoji-picker.svg?style=flat)](https://github.com/ferrucc-io/emojicn/blob/main/LICENSE) 11 | 12 |
13 | 14 |
15 | Emojicn Demo 16 |
17 | 18 |
19 |

20 | Live Demo 21 |

22 |
23 | 24 | ## Installation 25 | 26 | To use this component your project must be using React and Tailwind. 27 | 28 | To install the component: 29 | 30 | ```bash 31 | bun add @ferrucc-io/emoji-picker 32 | # or 33 | yarn add @ferrucc-io/emoji-picker 34 | # or 35 | npm i @ferrucc-io/emoji-picker 36 | # or 37 | pnpm add @ferrucc-io/emoji-picker 38 | ``` 39 | 40 | Then in your project you can use the component like this: 41 | 42 | ```tsx 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ``` 52 | 53 | Finally, import the component styles in your Tailwind configuration: 54 | 55 | ```tsx 56 | // If you're using Tailwind v4.x 57 | // In your CSS file containing your tailwind configuration 58 | // Add this line: 59 | @source "../node_modules/@ferrucc-io/emoji-picker"; 60 | 61 | // If you're using Tailwind v3.x 62 | // In your tailwind.config.ts 63 | "content": [ 64 | // Keep the existing content array 65 | // Add this line: 66 | "./node_modules/@ferrucc-io/emoji-picker/dist/**/*.{js,jsx,ts,tsx}", 67 | ], 68 | ``` 69 | 70 | ## Features 71 | 72 | - 🎨 **Unstyled & Composable**: Built with Tailwind CSS. Every component is minimally styled by default and fully customizable. 73 | - ⚡️ **Fast & Lightweight**: Virtualized list for smooth scrolling. Only renders emojis in view. 74 | - 🎯 **Accessible**: Full keyboard navigation support. ARIA labels and proper semantic markup. 75 | - 🌈 **Dominant Color Hover**: Built-in dominant color hover for supported emojis. 76 | - 🔄 **React Compatibility**: Works with React 18 and React 19. 77 | 78 | 79 | 80 | ## Default Style 81 | ```tsx 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ``` 91 | 92 | ### Props & Customization 93 | 94 | The base component accepts several props for customization: 95 | 96 | ```tsx 97 | interface EmojiPickerProps { 98 | emojisPerRow?: number; // Number of emojis per row 99 | emojiSize?: number; // Size of each emoji in pixels 100 | containerHeight?: number; // Height of the emoji container 101 | maxUnicodeVersion?: number; // Maximum Unicode version to include in the list of emojis (we default to 15.0) 102 | onEmojiSelect?: (emoji: string) => void; // Callback when emoji is selected 103 | } 104 | ``` 105 | 106 | ## Examples 107 | 108 | The main idea behind the component is to be able to support as many different styles as possible. The first version we made supports building a picker that looks like the ones in Slack and Linear. 109 | 110 | In the future it would be great to support more styles like the ones in Discord, Whatsapp, Notion etc. 111 | 112 | 113 | ### Linear Style 114 | ```tsx 115 | 121 | 122 | 127 | 128 | 129 | 130 | 131 | 132 | ``` 133 | 134 | ### Slack Style 135 | ```tsx 136 | 141 | 142 | 147 | 148 | 149 | 150 | 151 | 152 | {({ previewedEmoji }) => ( 153 | <> 154 | {previewedEmoji ? 155 | 156 | : 157 | 158 | } 159 | 160 | 161 | )} 162 | 163 | 164 | ``` 165 | 166 | ## Used by 167 | 168 | This project is currently used by: 169 | 170 | - [June](https://june.so) - An analytics product for B2B SaaS 171 | - [Typefully](https://typefully.com) - The writing app for X, LinkedIn, Threads and more 172 | 173 | **Feel free to open a PR to add your name here if you're using the component in your project.** 174 | 175 | 176 | ## Credits 177 | 178 | This project was created using `bun init` in bun v1.2.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 179 | 180 | 181 | ## Contributing 182 | 183 | This project is structured as a monorepo with two main parts: 184 | - `packages/emoji-picker`: The main package containing the emoji picker component 185 | - `demo`: A demo application showcasing different uses of the component 186 | 187 | ### Development Setup 188 | 189 | To get started with development: 190 | 191 | ```bash 192 | # Install dependencies for all packages 193 | bun install 194 | 195 | # Run the development server 196 | bun dev 197 | ``` 198 | 199 | ### Updating Emoji Colors 200 | 201 | To update the emoji hover colors: 202 | 203 | ```bash 204 | cd packages/emoji-picker 205 | bun run build:emoji-colors 206 | ``` 207 | 208 | This will generate a new `emojiColors.ts` file in the package's `src/utils` directory. 209 | 210 | ### Testing 211 | 212 | ```bash 213 | # Run tests for the emoji picker package 214 | cd packages/emoji-picker 215 | bun test 216 | ``` 217 | 218 | Contributions are welcome! Please feel free to submit a PR. 219 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ferruccio's Emoji Picker 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ferrucc-io/emoji-picker-demo", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "bunx --bun vite", 8 | "build:types": "bunx --bun tsc", 9 | "build:stats": "bun run scripts/updateNpmStats.ts", 10 | "build": "bun run build:stats && bun run build:types && bunx --bun vite build", 11 | "preview": "bunx --bun vite preview", 12 | "deploy": "gh-pages -d dist", 13 | "lint": "bunx --bun oxlint .", 14 | "format": "bunx --bun prettier --write .", 15 | "format:check": "bunx --bun prettier --check ." 16 | }, 17 | "dependencies": { 18 | "@ferrucc-io/emoji-picker": "workspace:*", 19 | "prism-react-renderer": "^2.3.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0" 22 | }, 23 | "devDependencies": { 24 | "@tailwindcss/vite": "^4.0.0", 25 | "@types/react": "^18.2.55", 26 | "@types/react-copy-to-clipboard": "^5.0.7", 27 | "@types/react-dom": "^18.2.19", 28 | "@typescript-eslint/eslint-plugin": "^6.21.0", 29 | "@typescript-eslint/parser": "^6.21.0", 30 | "@vitejs/plugin-react": "^4.2.1", 31 | "eslint": "^8.56.0", 32 | "eslint-config-prettier": "^9.1.0", 33 | "eslint-plugin-react": "^7.33.2", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "gh-pages": "^6.1.1", 36 | "oxlint": "^0.15.8", 37 | "prettier": "^3.2.5", 38 | "typescript": "^5.3.3", 39 | "vite": "^5.1.4", 40 | "vite-plugin-oxlint": "^1.2.2" 41 | }, 42 | "resolutions": { 43 | "@types/react": "18.2.0", 44 | "@types/react-dom": "18.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demo/scripts/updateNpmStats.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import { dirname, join } from 'path'; 3 | import { writeFileSync } from 'fs'; 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); 6 | 7 | async function updateNpmStats() { 8 | try { 9 | const response = await fetch( 10 | "https://api.npmjs.org/downloads/point/last-month/@ferrucc-io/emoji-picker", 11 | ); 12 | const data = await response.json(); 13 | 14 | const statsContent = `// This value is updated during build time 15 | export const NPM_MONTHLY_DOWNLOADS = ${data.downloads};`; 16 | 17 | writeFileSync( 18 | join(__dirname, "../src/constants/npmStats.ts"), 19 | statsContent, 20 | ); 21 | 22 | console.log("Successfully updated NPM stats"); 23 | } catch (error) { 24 | console.error("Failed to update NPM stats:", error); 25 | // Don't fail the build, just keep the default value 26 | } 27 | } 28 | 29 | updateNpmStats(); 30 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useTheme } from './ThemeContext'; 3 | import { Playground } from './playground'; 4 | import { Hero } from './Hero'; 5 | import { NPM_MONTHLY_DOWNLOADS } from './constants/npmStats'; 6 | import { TailwindSetupTabs } from './components/TailwindSetupTabs'; 7 | import { SocialProof } from './components/SocialProof'; 8 | import { PackageManagerTabs } from './components/PackageManagerTabs'; 9 | import { NpmDownloadsPill } from './components/NpmDownloadsPill'; 10 | import { Features } from './components/Features'; 11 | import { FAQ } from './components/FAQ'; 12 | 13 | const EXAMPLE_CODE = { 14 | default: ` 15 | 16 | 17 | 18 | 19 | 20 | 21 | `, 22 | linear: ` 28 | 29 | 34 | 35 | 36 | 37 | 38 | `, 39 | slack: ` 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | {({ previewedEmoji }) => ( 56 | <> 57 | {previewedEmoji ? 58 | 59 | : 60 | 61 | } 62 | 63 | 64 | )} 65 | 66 | `, 67 | }; 68 | 69 | 70 | function App() { 71 | const { theme } = useTheme(); 72 | const [selectedEmoji, setSelectedEmoji] = useState("⌘"); 73 | 74 | return ( 75 |
78 |
79 |
80 | 81 |
82 | 86 | 92 | 98 | 99 | 100 | 101 |
102 |
103 | 104 |
105 |
106 | 107 |
108 |
109 | 110 |
111 | 112 |
113 | 114 |
115 |

116 | Installation 117 |

118 | 119 |

120 | Tailwind Setup 121 |

122 | 123 |
124 | 125 |
126 | 127 | 128 |
129 |
130 |
131 | ); 132 | } 133 | 134 | export default App; 135 | -------------------------------------------------------------------------------- /demo/src/Hero.tsx: -------------------------------------------------------------------------------- 1 | interface HeroProps { 2 | selectedEmoji: string; 3 | } 4 | 5 | export const Hero = ({ selectedEmoji }: HeroProps) => { 6 | return ( 7 |
8 |
9 | {selectedEmoji} 10 |
11 |
12 |

13 | Emoji Picker 14 |

15 |

16 | A fast, composable, unstyled emoji picker made with Tailwind & 17 | React 18 |

19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Tab { 4 | id: T; 5 | label: string; 6 | } 7 | 8 | interface TabsProps { 9 | tabs: readonly Tab[]; 10 | activeTab: T; 11 | onTabChange: (tab: T) => void; 12 | } 13 | 14 | export function Tabs({ 15 | tabs, 16 | activeTab, 17 | onTabChange, 18 | }: TabsProps) { 19 | return ( 20 |
21 | {tabs.map(({ id, label }) => ( 22 | 33 | ))} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /demo/src/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | type Theme = "light" | "dark"; 4 | 5 | interface ThemeContextType { 6 | theme: Theme; 7 | toggleTheme: () => void; 8 | } 9 | 10 | const ThemeContext = createContext(null); 11 | 12 | export function useTheme() { 13 | const context = useContext(ThemeContext); 14 | if (!context) { 15 | throw new Error("useTheme must be used within a ThemeProvider"); 16 | } 17 | return context; 18 | } 19 | 20 | export function ThemeProvider({ children }: { children: React.ReactNode }) { 21 | const [theme, setTheme] = useState(() => { 22 | // Check if theme is stored in localStorage 23 | const storedTheme = localStorage.getItem("theme"); 24 | if (storedTheme === "light" || storedTheme === "dark") { 25 | return storedTheme; 26 | } 27 | // Check system preference 28 | return window.matchMedia("(prefers-color-scheme: dark)").matches 29 | ? "dark" 30 | : "light"; 31 | }); 32 | 33 | useEffect(() => { 34 | // Update localStorage and document class when theme changes 35 | localStorage.setItem("theme", theme); 36 | document.documentElement.classList.toggle("dark", theme === "dark"); 37 | }, [theme]); 38 | 39 | const toggleTheme = () => { 40 | setTheme((prev) => (prev === "light" ? "dark" : "light")); 41 | }; 42 | 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /demo/src/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTheme } from "./ThemeContext"; 3 | 4 | export function ThemeToggle() { 5 | const { theme, toggleTheme } = useTheme(); 6 | 7 | return ( 8 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /demo/src/components/ClipboardButton.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useState } from 'react'; 3 | 4 | export const ClipboardButton = ({ text }: { text: string }) => { 5 | const [copied, setCopied] = useState(false); 6 | 7 | const handleCopy = async () => { 8 | try { 9 | await navigator.clipboard.writeText(text); 10 | setCopied(true); 11 | setTimeout(() => setCopied(false), 2000); 12 | } catch (err) { 13 | console.error("Failed to copy text:", err); 14 | } 15 | }; 16 | 17 | return ( 18 | 43 | ); 44 | }; 45 | 46 | -------------------------------------------------------------------------------- /demo/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Highlight } from 'prism-react-renderer'; 2 | import { ClipboardButton } from './ClipboardButton'; 3 | 4 | interface CodeBlockProps { 5 | code: string; 6 | language?: string; 7 | showLineNumbers?: boolean; 8 | hideCopyButton?: boolean; 9 | } 10 | 11 | export function CodeBlock({ 12 | code, 13 | language = "typescript", 14 | showLineNumbers = false, 15 | hideCopyButton = false, 16 | }: CodeBlockProps) { 17 | 18 | return ( 19 |
20 | {/* @ts-ignore - Highlight types are incorrect but component works fine */} 21 | 22 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 23 |
24 | {!hideCopyButton && ( 25 |
26 | 27 |
28 | )} 29 |
33 |               {tokens.map((line, i) => (
34 |                 
35 | {showLineNumbers && ( 36 | 37 | {i + 1} 38 | 39 | )} 40 | {line.map((token, key) => ( 41 | 42 | ))} 43 |
44 | ))} 45 |
46 |
47 | )} 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /demo/src/components/FAQ.tsx: -------------------------------------------------------------------------------- 1 | interface FAQItem { 2 | question: string; 3 | answer: React.ReactNode; 4 | } 5 | 6 | const FAQ_ITEMS: FAQItem[] = [ 7 | { 8 | question: "What are the peer dependencies?", 9 | answer: "React ≥0.14.0, React DOM ≥0.14.0 and Tailwind CSS ≥3.0.0", 10 | }, 11 | { 12 | question: "Is it customizable?", 13 | answer: "Yes! The component is unstyled by default and uses Tailwind CSS for styling. You can customize the appearance using Tailwind classes or your own CSS.", 14 | }, 15 | { 16 | question: "How does keyboard navigation work?", 17 | answer: "Use arrow keys to navigate through emojis, Enter to select, and Escape to clear search. Tab and Shift+Tab to move between interactive elements.", 18 | }, 19 | { 20 | question: "Does it support all emojis?", 21 | answer: "Yes, it includes all emojis up to Unicode 15.0. You can filter out newer emojis using the maxUnicodeVersion prop for better compatibility. It should also work with newer emojis versions, but I haven't tested it yet.", 22 | }, 23 | { 24 | question: "What is the license?", 25 | answer: ( 26 | <> 27 | MIT. See the{" "} 28 | 34 | LICENSE 35 | {" "} 36 | file for more details. 37 | 38 | ), 39 | }, 40 | { 41 | question: "Where can I find more examples?", 42 | answer: ( 43 | <> 44 | Check out the examples above or visit our{" "} 45 | 51 | GitHub repository 52 | {" "} 53 | for more examples and documentation. 54 | 55 | ), 56 | }, 57 | ]; 58 | 59 | export function FAQ() { 60 | return ( 61 |
62 |

63 | FAQ 64 |

65 |
66 | {FAQ_ITEMS.map((item) => ( 67 |
68 |

69 | {item.question} 70 |

71 |

72 | {item.answer} 73 |

74 |
75 | ))} 76 |
77 |
78 | ); 79 | } -------------------------------------------------------------------------------- /demo/src/components/Features.tsx: -------------------------------------------------------------------------------- 1 | interface Feature { 2 | icon: string; 3 | title: string; 4 | description: string; 5 | } 6 | 7 | const FEATURES: Feature[] = [ 8 | { 9 | icon: "🎨", 10 | title: "Unstyled & Composable", 11 | description: "Built with Tailwind CSS. Every component is minimally styled by default and fully customizable.", 12 | }, 13 | { 14 | icon: "⚡️", 15 | title: "Fast & Lightweight", 16 | description: "Virtualized list for smooth scrolling. Only renders emojis in view.", 17 | }, 18 | { 19 | icon: "🎯", 20 | title: "Accessible", 21 | description: "Full keyboard navigation support. ARIA labels and proper semantic markup.", 22 | }, 23 | { 24 | icon: "🌈", 25 | title: "Dominant Color Hover", 26 | description: "Built-in dominant color hover for supported emojis.", 27 | }, 28 | ]; 29 | 30 | export function Features() { 31 | return ( 32 |
33 |

34 | Features 35 |

36 |
37 | {FEATURES.map((feature) => ( 38 |
39 |

40 | {feature.icon} {feature.title} 41 |

42 |

43 | {feature.description} 44 |

45 |
46 | ))} 47 |
48 |
49 | ); 50 | } -------------------------------------------------------------------------------- /demo/src/components/NpmDownloadsPill.tsx: -------------------------------------------------------------------------------- 1 | interface NpmDownloadsProps { 2 | packageName: string; 3 | defaultDownloads: number; 4 | } 5 | 6 | export function NpmDownloadsPill({ 7 | packageName, 8 | defaultDownloads, 9 | }: NpmDownloadsProps) { 10 | return ( 11 | 17 | 18 | 30 | 31 | 32 | 33 | 34 | 35 | {new Intl.NumberFormat("en-US", { notation: "compact" }).format( 36 | defaultDownloads, 37 | )} 38 | /month 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /demo/src/components/PackageManagerTabs.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ClipboardButton } from './ClipboardButton'; 3 | 4 | interface PackageManagerTabsProps { 5 | packageName: string; 6 | } 7 | 8 | type PackageManager = "npm" | "bun" | "yarn" | "pnpm"; 9 | 10 | export function PackageManagerTabs({ packageName }: PackageManagerTabsProps) { 11 | const [activeTab, setActiveTab] = useState("npm"); 12 | 13 | 14 | const commands: Record = { 15 | npm: `npm install ${packageName}`, 16 | bun: `bun add ${packageName}`, 17 | yarn: `yarn add ${packageName}`, 18 | pnpm: `pnpm add ${packageName}`, 19 | }; 20 | 21 | 22 | return ( 23 |
24 |
25 |
26 |
27 | {(Object.keys(commands) as PackageManager[]).map((manager) => ( 28 | 39 | ))} 40 |
41 |
42 |
43 | 44 |
45 |
46 |           {commands[activeTab]}
47 |         
48 |
49 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /demo/src/components/SocialProof.tsx: -------------------------------------------------------------------------------- 1 | import { TypefullyLogo } from '../social-proof/typefully'; 2 | import { JuneLogo } from '../social-proof/june'; 3 | 4 | export const SocialProof = () => { 5 | return ( 6 |
7 |

8 | Used in production by 9 |

10 | 35 |
36 | ); 37 | }; -------------------------------------------------------------------------------- /demo/src/components/TailwindSetupTabs.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { CodeBlock } from './CodeBlock'; 3 | import { ClipboardButton } from './ClipboardButton'; 4 | 5 | type TailwindVersion = "v3" | "v4"; 6 | 7 | const TAILWIND_CONFIGS: Record = { 8 | v4: { 9 | description: "In your Tailwind configuration CSS file, add this line after your Tailwind import:", 10 | code: `@import "tailwindcss"; 11 | // ... your other imports 12 | @source "../node_modules/@ferrucc-io/emoji-picker";`, 13 | copyText: '@source "../node_modules/@ferrucc-io/emoji-picker";', 14 | }, 15 | v3: { 16 | description: "In your tailwind.config.js/ts file, add this to the content array:", 17 | code: `/** @type {import('tailwindcss').Config} */ 18 | export default { 19 | content: [ 20 | "./src/**/*.{js,jsx,ts,tsx}", 21 | // ... your existing paths 22 | "./node_modules/@ferrucc-io/emoji-picker/dist/**/*.{js,jsx,ts,tsx}" 23 | ], 24 | // ... rest of your config 25 | }`, 26 | copyText: '"./node_modules/@ferrucc-io/emoji-picker/dist/**/*.{js,jsx,ts,tsx}"', 27 | }, 28 | }; 29 | 30 | export function TailwindSetupTabs() { 31 | const [activeTab, setActiveTab] = useState("v4"); 32 | 33 | return ( 34 |
35 |
36 |
37 |
38 | {(Object.keys(TAILWIND_CONFIGS) as TailwindVersion[]).map((version) => ( 39 | 50 | ))} 51 |
52 |
53 |
54 | 55 |
56 |

57 | {TAILWIND_CONFIGS[activeTab].description} 58 |

59 |
60 | 61 | 62 |
63 |
64 |
65 | ); 66 | } -------------------------------------------------------------------------------- /demo/src/constants/npmStats.ts: -------------------------------------------------------------------------------- 1 | // This value is updated during build time 2 | export const NPM_MONTHLY_DOWNLOADS = 1049; -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Lato:wght@400;500;600;700&display=swap"); 2 | @import "tailwindcss"; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | @layer base { 7 | :root { 8 | color-scheme: light dark; 9 | --background: 240 10% 3.9%; 10 | --foreground: 0 0% 98%; 11 | --card: 240 10% 3.9%; 12 | --card-foreground: 0 0% 98%; 13 | --popover: 240 10% 3.9%; 14 | --popover-foreground: 0 0% 98%; 15 | --primary: 0 0% 98%; 16 | --primary-foreground: 240 5.9% 10%; 17 | --secondary: 240 3.7% 15.9%; 18 | --secondary-foreground: 0 0% 98%; 19 | --muted: 240 3.7% 15.9%; 20 | --muted-foreground: 240 5% 64.9%; 21 | --accent: 240 3.7% 15.9%; 22 | --accent-foreground: 0 0% 98%; 23 | --destructive: 0 62.8% 30.6%; 24 | --destructive-foreground: 0 0% 98%; 25 | --border: 240 3.7% 15.9%; 26 | --input: 240 3.7% 15.9%; 27 | --ring: 240 4.9% 83.9%; 28 | --fallback-hover-color: rgba(0, 0, 0, 0.05); 29 | } 30 | 31 | .dark { 32 | --fallback-hover-color: rgba(255, 255, 255, 0.1); 33 | } 34 | } 35 | 36 | body { 37 | margin: 0; 38 | min-height: 100vh; 39 | background-color: hsl(var(--background)); 40 | color: hsl(var(--foreground)); 41 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 42 | "Helvetica Neue", Arial, sans-serif; 43 | } 44 | -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import ReactDOM from "react-dom/client"; 3 | import React from "react"; 4 | import { ThemeProvider } from "./ThemeContext"; 5 | import App from "./App"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /demo/src/playground/DefaultPickerExample.tsx: -------------------------------------------------------------------------------- 1 | import { EmojiPicker } from '@ferrucc-io/emoji-picker'; 2 | 3 | interface DefaultPickerExampleProps { 4 | onEmojiSelect: (emoji: string) => void; 5 | } 6 | 7 | export function DefaultPickerExample({ onEmojiSelect }: DefaultPickerExampleProps) { 8 | return ( 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /demo/src/playground/LinearPickerExample.tsx: -------------------------------------------------------------------------------- 1 | import { EmojiPicker } from '@ferrucc-io/emoji-picker'; 2 | 3 | interface LinearPickerExampleProps { 4 | onEmojiSelect: (emoji: string) => void; 5 | } 6 | 7 | export function LinearPickerExample({ onEmojiSelect }: LinearPickerExampleProps) { 8 | return ( 9 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/playground/Playground.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PlaygroundProps } from './types'; 3 | import { SlackPickerExample } from './SlackPickerExample'; 4 | import { LinearPickerExample } from './LinearPickerExample'; 5 | import { DefaultPickerExample } from './DefaultPickerExample'; 6 | import { useTheme } from '../ThemeContext'; 7 | import { CodeBlock } from '../components/CodeBlock'; 8 | 9 | const THEMES = [ 10 | { value: "light", label: "Light" }, 11 | { value: "dark", label: "Dark" }, 12 | ] as const; 13 | 14 | const TABS = [ 15 | { id: "default" as const, label: "Default" }, 16 | { id: "slack" as const, label: "Slack" }, 17 | { id: "linear" as const, label: "Linear" }, 18 | ] as const; 19 | 20 | type VariantType = (typeof TABS)[number]["id"]; 21 | 22 | export function Playground({ 23 | code, 24 | onEmojiSelect, 25 | }: Omit) { 26 | const [activeTab, setActiveTab] = useState<"preview" | "code">("preview"); 27 | const [variant, setVariant] = useState("default"); 28 | const { theme, toggleTheme } = useTheme(); 29 | 30 | const renderPicker = () => { 31 | switch (variant) { 32 | case "linear": 33 | return ; 34 | case "slack": 35 | return ; 36 | default: 37 | return ; 38 | } 39 | }; 40 | 41 | return ( 42 |
43 |
44 |
45 | 46 | Style: 47 | 48 |
49 | {TABS.map((tab) => ( 50 | 61 | ))} 62 |
63 |
64 |
65 |
66 | 67 | Theme: 68 | 69 |
70 | {THEMES.map(({ value, label }) => ( 71 | 82 | ))} 83 |
84 |
85 |
86 | 87 |
88 | 108 | 127 |
128 | 129 |
130 |
131 | {activeTab === "preview" ? ( 132 | renderPicker() 133 | ) : ( 134 |
135 | 136 |
137 | )} 138 |
139 |
140 |
141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /demo/src/playground/SlackPickerExample.tsx: -------------------------------------------------------------------------------- 1 | import { EmojiPicker } from '@ferrucc-io/emoji-picker'; 2 | 3 | interface SlackPickerExampleProps { 4 | onEmojiSelect: (emoji: string) => void; 5 | } 6 | 7 | export function SlackPickerExample({ onEmojiSelect }: SlackPickerExampleProps) { 8 | return ( 9 |
10 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | {({ previewedEmoji }) => ( 28 | <> 29 | {previewedEmoji ? ( 30 | 31 | ) : ( 32 | 35 | )} 36 | 37 | 38 | )} 39 | 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /demo/src/playground/index.ts: -------------------------------------------------------------------------------- 1 | export { Playground } from "./Playground"; 2 | export type { PlaygroundProps } from "./types"; 3 | -------------------------------------------------------------------------------- /demo/src/playground/types.ts: -------------------------------------------------------------------------------- 1 | export type VariantType = "default" | "linear" | "slack"; 2 | 3 | export interface PlaygroundProps { 4 | code: Record; 5 | onEmojiSelect: (emoji: string) => void; 6 | } 7 | -------------------------------------------------------------------------------- /demo/src/social-proof/june.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const JuneLogo: React.FC = () => ( 4 | 5 | 6 | 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /demo/src/social-proof/typefully.tsx: -------------------------------------------------------------------------------- 1 | export const TypefullyLogo: React.FC = () => ( 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ) -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "allowJs": true, 24 | "esModuleInterop": true, 25 | "allowSyntheticDefaultImports": true, 26 | "forceConsistentCasingInFileNames": true 27 | }, 28 | "include": ["src"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import oxlintPlugin from 'vite-plugin-oxlint'; 2 | import { defineConfig, PluginOption } from 'vite'; 3 | import { resolve } from 'path'; 4 | import react from '@vitejs/plugin-react'; 5 | import tailwindcss from '@tailwindcss/vite'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | tailwindcss(), 12 | oxlintPlugin(), 13 | ] as PluginOption[], 14 | base: "/", 15 | build: { 16 | outDir: "dist", 17 | }, 18 | resolve: { 19 | alias: [ 20 | { 21 | find: "@ferrucc-io/emoji-picker", 22 | replacement: resolve( 23 | __dirname, 24 | "../packages/emoji-picker/src/index.ts", 25 | ), 26 | }, 27 | { 28 | find: "@", 29 | replacement: resolve( 30 | __dirname, 31 | "../packages/emoji-picker/src", 32 | ), 33 | }, 34 | ], 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ferrucc-io/emoji-picker-monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "demo" 7 | ], 8 | "scripts": { 9 | "dev": "bun run --cwd demo dev", 10 | "build": "bun run --cwd packages/emoji-picker build && bun run --cwd demo build", 11 | "deploy": "bun run build && bun run --cwd demo deploy", 12 | "test": "bun --cwd packages/emoji-picker test", 13 | "format": "bun --cwd packages/emoji-picker format", 14 | "format:check": "bun --cwd packages/emoji-picker format:check", 15 | "lint": "bun --cwd packages/emoji-picker lint", 16 | "test:watch": "bun --cwd packages/emoji-picker test --watch", 17 | "test:coverage": "bun --cwd packages/emoji-picker test --coverage", 18 | "release:patch": "bun run --cwd packages/emoji-picker release:patch", 19 | "release:minor": "bun run --cwd packages/emoji-picker release:minor", 20 | "release:major": "bun run --cwd packages/emoji-picker release:major" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/emoji-picker/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "prettier" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": ["@typescript-eslint", "react", "react-hooks"], 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | } 15 | }, 16 | "rules": { 17 | "react/react-in-jsx-scope": "off", 18 | "react/prop-types": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "no-case-declarations": "off", 21 | "no-constant-condition": "off", 22 | "react-hooks/exhaustive-deps": "warn", 23 | "@typescript-eslint/no-unused-vars": [ 24 | "warn", 25 | { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } 26 | ] 27 | }, 28 | "ignorePatterns": ["dist/", "node_modules/", "*.js"] 29 | } 30 | -------------------------------------------------------------------------------- /packages/emoji-picker/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 100, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /packages/emoji-picker/README.md: -------------------------------------------------------------------------------- 1 | # Emoji Picker - React Emoji picker component 2 | 3 | This is a composable React component that allows your users to pick an emoji. Styled with Tailwind and supports custom styling. 4 | 5 | This component is heavily inspired by ShadCN and works well alongside it. 6 | 7 | ![Emojicn](./public/emoji.png) 8 | [Live Demo](https://emoji.ferrucc.io) 9 | 10 | ## Installation 11 | 12 | To use this component your project must be using React and Tailwind. 13 | 14 | To install the component: 15 | 16 | ```bash 17 | bun add @ferrucc-io/emoji-picker 18 | # or 19 | yarn add @ferrucc-io/emoji-picker 20 | # or 21 | npm i @ferrucc-io/emoji-picker 22 | # or 23 | pnpm add @ferrucc-io/emoji-picker 24 | ``` 25 | 26 | Then in your project you can use the component like this: 27 | 28 | ```tsx 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ``` 38 | 39 | ## Features 40 | 41 | - 🎨 **Unstyled & Composable**: Built with Tailwind CSS. Every component is minimally styled by default and fully customizable. 42 | - ⚡️ **Fast & Lightweight**: Virtualized list for smooth scrolling. Only renders emojis in view. 43 | - 🎯 **Accessible**: Full keyboard navigation support. ARIA labels and proper semantic markup. 44 | - 🌈 **Dominant Color Hover**: Built-in dominant color hover for supported emojis. 45 | 46 | ## Props & Customization 47 | 48 | The component accepts several props for customization: 49 | 50 | ```tsx 51 | interface EmojiPickerProps { 52 | emojisPerRow?: number; // Number of emojis per row 53 | emojiSize?: number; // Size of each emoji in pixels 54 | containerHeight?: number; // Height of the emoji container 55 | hideIcon?: boolean; // Hide the search icon 56 | onEmojiSelect?: (emoji: string) => void; // Callback when emoji is selected 57 | } 58 | ``` 59 | 60 | ## Examples 61 | 62 | ### Default Style 63 | 64 | ```tsx 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ``` 74 | 75 | ### Linear Style 76 | 77 | ```tsx 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ``` 87 | 88 | ### Slack Style 89 | 90 | ```tsx 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | {({ previewedEmoji }) => ( 100 | <> 101 | {previewedEmoji ? : } 102 | 103 | 104 | )} 105 | 106 | 107 | ``` 108 | 109 | ## Credits 110 | 111 | This project was created using `bun init` in bun v1.2.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 112 | 113 | ## Contributing 114 | 115 | This project is structured as a monorepo with two main parts: 116 | 117 | - `packages/emoji-picker`: The main package containing the emoji picker component 118 | - `demo`: A demo application showcasing different uses of the component 119 | 120 | ### Development Setup 121 | 122 | To get started with development: 123 | 124 | ```bash 125 | # Install dependencies for all packages 126 | bun install 127 | 128 | # Build the emoji picker package 129 | cd packages/emoji-picker 130 | bun run build 131 | 132 | # Run the demo app 133 | cd ../demo 134 | bun run dev 135 | ``` 136 | 137 | ### Updating Emoji Colors 138 | 139 | To update the emoji hover colors: 140 | 141 | ```bash 142 | cd packages/emoji-picker 143 | bun run build:emoji-colors 144 | ``` 145 | 146 | This will generate a new `emojiColors.ts` file in the package's `src/utils` directory. 147 | 148 | ### Testing 149 | 150 | ```bash 151 | # Run tests for the emoji picker package 152 | cd packages/emoji-picker 153 | bun test 154 | ``` 155 | 156 | Contributions are welcome! Please feel free to submit a PR. 157 | -------------------------------------------------------------------------------- /packages/emoji-picker/build.ts: -------------------------------------------------------------------------------- 1 | import type { BuildConfig } from 'bun'; 2 | import { spawnSync } from 'child_process'; 3 | 4 | const defaultBuildConfig: BuildConfig = { 5 | entrypoints: ['./src/index.ts'], 6 | outdir: './dist', 7 | external: ['react', 'react-dom'], 8 | minify: true, 9 | root: './src', 10 | }; 11 | 12 | // Build JS bundles 13 | await Promise.all([ 14 | Bun.build({ 15 | ...defaultBuildConfig, 16 | format: 'esm', 17 | naming: '[name].js', 18 | }), 19 | Bun.build({ 20 | ...defaultBuildConfig, 21 | format: 'cjs', 22 | naming: '[name].cjs', 23 | }), 24 | ]); 25 | 26 | // Generate type declarations 27 | spawnSync('tsc', ['--project', 'tsconfig.build.json'], { stdio: 'inherit' }); 28 | -------------------------------------------------------------------------------- /packages/emoji-picker/bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = ["./src/test/happydom.ts", "./src/test/setup.ts"] -------------------------------------------------------------------------------- /packages/emoji-picker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ferrucc-io/emoji-picker", 3 | "version": "0.0.44", 4 | "description": "A beautiful and modern emoji picker for React", 5 | "main": "./dist/index.cjs", 6 | "module": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "type": "module", 9 | "files": [ 10 | "dist" 11 | ], 12 | "exports": { 13 | ".": { 14 | "types": "./dist/index.d.ts", 15 | "import": "./dist/index.js", 16 | "require": "./dist/index.cjs", 17 | "default": "./dist/index.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "sideEffects": false, 22 | "scripts": { 23 | "dev": "bunx --bun vite", 24 | "test": "bun test", 25 | "test:watch": "bun test --watch", 26 | "test:coverage": "bun test --coverage", 27 | "test:react-versions": "bun run scripts/test-react-versions.ts", 28 | "build:emoji-colors": "bun run scripts/generateEmojiColors.ts", 29 | "build:types": "bunx --bun tsc -p tsconfig.build.json", 30 | "build:esm": "bunx esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --external:react --external:react-dom --jsx=automatic --jsx-factory=React.createElement --jsx-fragment=React.Fragment", 31 | "build:cjs": "bunx esbuild src/index.ts --bundle --format=cjs --outfile=dist/index.cjs --external:react --external:react-dom --jsx=automatic --jsx-factory=React.createElement --jsx-fragment=React.Fragment", 32 | "build": "rm -rf dist && bun run build:types && bun run build:esm && bun run build:cjs", 33 | "prepublishOnly": "cp ../../README.md . && bun run build", 34 | "clean": "rm -rf dist", 35 | "release:patch": "bun run scripts/release.ts patch", 36 | "release:minor": "bun run scripts/release.ts minor", 37 | "release:major": "bun run scripts/release.ts major", 38 | "lint": "bunx --bun eslint . --ext .ts,.tsx", 39 | "format": "bunx --bun prettier --write .", 40 | "format:check": "bunx --bun prettier --check .", 41 | "prepack": "bun run build" 42 | }, 43 | "devDependencies": { 44 | "@happy-dom/global-registrator": "^16.7.3", 45 | "@tailwindcss/vite": "^4.0.0", 46 | "@testing-library/jest-dom": "^6.4.2", 47 | "@testing-library/react": "^14.2.1", 48 | "@types/bun": "latest", 49 | "@types/node-emoji": "^2.1.0", 50 | "@types/react": "^18.2.55", 51 | "@types/react-dom": "^18.2.19", 52 | "@typescript-eslint/eslint-plugin": "^6.21.0", 53 | "@typescript-eslint/parser": "^6.21.0", 54 | "@vitejs/plugin-react": "^4.3.4", 55 | "esbuild": "^0.20.1", 56 | "eslint": "^8.56.0", 57 | "eslint-config-prettier": "^9.1.0", 58 | "eslint-plugin-react": "^7.33.2", 59 | "eslint-plugin-react-hooks": "^4.6.0", 60 | "prettier": "^3.2.5", 61 | "react": "^18.2.0", 62 | "react-dom": "^18.2.0", 63 | "sharp": "^0.33.2", 64 | "vite": "^6.0.11", 65 | "typescript": ">=5.0.0" 66 | }, 67 | "peerDependencies": { 68 | "react": "^18.2.0 || ^19.0.0", 69 | "react-dom": "^18.2.0 || ^19.0.0", 70 | "tailwindcss": ">=3.0.0" 71 | }, 72 | "dependencies": { 73 | "@tanstack/react-virtual": "^3.11.2", 74 | "clsx": "^2.1.1", 75 | "jotai": "^2.11.1", 76 | "node-emoji": "^2.1.3", 77 | "tailwind-merge": "^2.6.0", 78 | "unicode-emoji-json": "^0.8.0" 79 | }, 80 | "keywords": [ 81 | "react-component", 82 | "emoji", 83 | "emoji-picker", 84 | "emoji-picker-react", 85 | "emoji-picker-react-component", 86 | "shadcn-emoji-picker" 87 | ], 88 | "author": "Your Name", 89 | "license": "MIT", 90 | "repository": { 91 | "type": "git", 92 | "url": "https://github.com/ferrucc-io/emoji-picker" 93 | }, 94 | "bugs": { 95 | "url": "https://github.com/ferrucc-io/emoji-picker/issues" 96 | }, 97 | "homepage": "https://emoji.ferrucc.io", 98 | "engines": { 99 | "node": ">=18" 100 | }, 101 | "publishConfig": { 102 | "access": "public" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/emoji-picker/react-version-tests/react-18.2.0/emojiPicker.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { afterEach, beforeEach, expect, test } from 'bun:test'; 3 | import { cleanup, render } from '@testing-library/react'; 4 | // Import test matchers 5 | import * as matchers from '@testing-library/jest-dom/matchers'; 6 | 7 | expect.extend(matchers); 8 | 9 | // Setup and teardown 10 | beforeEach(() => { 11 | cleanup(); 12 | }); 13 | 14 | afterEach(() => { 15 | cleanup(); 16 | }); 17 | 18 | // Create a wrapper component to ensure proper React context 19 | const TestWrapper = ({ children }) => { 20 | return
{children}
; 21 | }; 22 | 23 | test('EmojiPicker renders with React 18.2.0', () => { 24 | // Test the wrapper first to verify React is working 25 | const { getByTestId } = render( 26 | 27 |
Test
28 |
29 | ); 30 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 31 | }); 32 | 33 | test('EmojiPicker handles basic interactions', async () => { 34 | // Just verify React rendering works first 35 | const { getByTestId } = render( 36 | 37 | 38 | 39 | ); 40 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/emoji-picker/react-version-tests/react-18.2.0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emoji-picker-react-18.2.0-test", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "test": "bun test" 7 | }, 8 | "dependencies": { 9 | "@ferrucc-io/emoji-picker": "link:../..", 10 | "react": "18.2.0", 11 | "react-dom": "18.2.0" 12 | }, 13 | "devDependencies": { 14 | "@testing-library/react": "^14.2.1", 15 | "@happy-dom/global-registrator": "^16.7.3", 16 | "@testing-library/jest-dom": "^6.4.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/emoji-picker/react-version-tests/react-19.0.0/emojiPicker.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { afterEach, beforeEach, expect, test } from 'bun:test'; 3 | import { cleanup, render } from '@testing-library/react'; 4 | // Import test matchers 5 | import * as matchers from '@testing-library/jest-dom/matchers'; 6 | 7 | expect.extend(matchers); 8 | 9 | // Setup and teardown 10 | beforeEach(() => { 11 | cleanup(); 12 | }); 13 | 14 | afterEach(() => { 15 | cleanup(); 16 | }); 17 | 18 | // Create a wrapper component to ensure proper React context 19 | const TestWrapper = ({ children }) => { 20 | return
{children}
; 21 | }; 22 | 23 | test('EmojiPicker renders with React 19.0.0', () => { 24 | // Test the wrapper first to verify React is working 25 | const { getByTestId } = render( 26 | 27 |
Test
28 |
29 | ); 30 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 31 | }); 32 | 33 | test('EmojiPicker handles basic interactions', async () => { 34 | // Just verify React rendering works first 35 | const { getByTestId } = render( 36 | 37 | 38 | 39 | ); 40 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/emoji-picker/react-version-tests/react-19.0.0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emoji-picker-react-19.0.0-test", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "test": "bun test" 7 | }, 8 | "dependencies": { 9 | "@ferrucc-io/emoji-picker": "link:../..", 10 | "react": "19.0.0", 11 | "react-dom": "19.0.0" 12 | }, 13 | "devDependencies": { 14 | "@testing-library/react": "^14.2.1", 15 | "@happy-dom/global-registrator": "^16.7.3", 16 | "@testing-library/jest-dom": "^6.4.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/emoji-picker/scripts/__tests__/generateEmojiColors.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { readFileSync, writeFileSync } from 'fs'; 3 | import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; 4 | import { filterEmojis } from '../generateEmojiColors'; 5 | 6 | describe('emoji color generation', () => { 7 | const testColorFilePath = join(import.meta.dir, '../../src/utils/emojiColors.ts'); 8 | const backupFilePath = testColorFilePath + '.backup'; 9 | 10 | beforeEach(() => { 11 | // Backup existing file if it exists 12 | try { 13 | const content = readFileSync(testColorFilePath, 'utf-8'); 14 | writeFileSync(backupFilePath, content); 15 | } catch (error) { 16 | // File might not exist, that's ok 17 | } 18 | }); 19 | 20 | afterEach(() => { 21 | // Restore backup if it exists 22 | try { 23 | const content = readFileSync(backupFilePath, 'utf-8'); 24 | writeFileSync(testColorFilePath, content); 25 | } catch (error) { 26 | // No backup to restore, that's ok 27 | } 28 | }); 29 | 30 | test('filterEmojis function filters and adds skin tone variants correctly', () => { 31 | const mockEmojiData = { 32 | '👋': { 33 | name: 'waving hand', 34 | unicode_version: '1.0', 35 | emoji_version: '1.0', 36 | skin_tone_support: true, 37 | skin_tone_support_unicode_version: '1.0', 38 | }, 39 | '🌟': { 40 | name: 'glowing star', 41 | unicode_version: '1.0', 42 | emoji_version: '1.0', 43 | skin_tone_support: false, 44 | }, 45 | '🤖': { 46 | name: 'robot', 47 | unicode_version: '15.1', 48 | emoji_version: '15.1', 49 | skin_tone_support: false, 50 | }, 51 | }; 52 | 53 | const filtered = filterEmojis(mockEmojiData); 54 | 55 | // Should include base emoji and 5 skin tone variants for '👋' 56 | expect(Object.keys(filtered)).toContain('👋'); 57 | expect(Object.keys(filtered)).toContain('👋🏻'); 58 | expect(Object.keys(filtered)).toContain('👋🏼'); 59 | expect(Object.keys(filtered)).toContain('👋🏽'); 60 | expect(Object.keys(filtered)).toContain('👋🏾'); 61 | expect(Object.keys(filtered)).toContain('👋🏿'); 62 | 63 | // Should include '🌟' as is 64 | expect(Object.keys(filtered)).toContain('🌟'); 65 | 66 | // Should not include too recent emoji 67 | expect(Object.keys(filtered)).not.toContain('🤖'); 68 | }); 69 | 70 | test('generated colors file exists and has valid format', () => { 71 | // Create a minimal test file 72 | const testColors = { 73 | '👋': '#f4c0441f', 74 | '🌟': '#f4a81c1f', 75 | }; 76 | 77 | const fileContent = `// Auto-generated file - do not edit directly 78 | export const emojiColors: Record = ${JSON.stringify(testColors, null, 2)}; 79 | `; 80 | writeFileSync(testColorFilePath, fileContent); 81 | 82 | // Read and validate the file 83 | const content = readFileSync(testColorFilePath, 'utf-8'); 84 | expect(content).toContain('export const emojiColors'); 85 | 86 | // Parse the colors object 87 | const match = content.match(/\{([^}]+)\}/); 88 | expect(match).toBeTruthy(); 89 | 90 | const colorsObject = JSON.parse(`{${match![1]}}`); 91 | 92 | // Check if we have color entries 93 | expect(Object.keys(colorsObject).length).toBeGreaterThan(0); 94 | 95 | // Validate color format (6 characters for color + 2 for alpha) 96 | const someColor = Object.values(colorsObject)[0]; 97 | expect(someColor).toMatch(/^#[0-9A-F]{6}[0-9A-F]{2}$/i); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/emoji-picker/scripts/generateEmojiColors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Emoji Color Generator 5 | 6 | 100 | 101 | 102 |
103 | 109 |
110 |
111 |
112 |
113 | 114 | 115 | 233 | 234 | 235 | -------------------------------------------------------------------------------- /packages/emoji-picker/scripts/generateEmojiColors.ts: -------------------------------------------------------------------------------- 1 | import emojiData from 'unicode-emoji-json'; 2 | import sharp from 'sharp'; 3 | import { join } from 'path'; 4 | import { writeFileSync } from 'fs'; 5 | import { serve } from 'bun'; 6 | import { isCompatibleEmoji } from '../src/utils/emojiFilters'; 7 | 8 | const skinToneModifiers = ['🏻', '🏼', '🏽', '🏾', '🏿']; 9 | 10 | // Filter emojis that are too recent and add skin tone variants 11 | export function filterEmojis(emojiData: Record): Record { 12 | const filteredEntries = Object.entries(emojiData) 13 | .filter(([emoji, data]) => { 14 | const { isCompatible } = isCompatibleEmoji({ emoji, ...data }); 15 | return isCompatible; 16 | }) 17 | .flatMap(([emoji, data]) => { 18 | if (data.skin_tone_support) { 19 | // Add default version and all skin tone variants 20 | return [ 21 | [emoji, data], 22 | ...skinToneModifiers.map((modifier) => { 23 | const emojiWithTone = emoji + modifier; 24 | return [emojiWithTone, { ...data, skin_tone_variant: true }]; 25 | }), 26 | ]; 27 | } 28 | return [[emoji, data]]; 29 | }); 30 | 31 | return Object.fromEntries(filteredEntries); 32 | } 33 | 34 | // Helper function to create an SVG with the emoji 35 | function createEmojiSVG(emoji: string) { 36 | return ` 37 | 38 | 39 |
40 | ${emoji} 41 |
42 |
43 |
44 | `; 45 | } 46 | 47 | function rgbaToHex(r: number, g: number, b: number, opacity: number = 0.12): string { 48 | const hex = 49 | '#' + 50 | [r, g, b] 51 | .map((x) => { 52 | const hex = Math.round(x).toString(16); 53 | return hex.length === 1 ? '0' + hex : hex; 54 | }) 55 | .join(''); 56 | 57 | // Add opacity as hex 58 | const alpha = Math.round(opacity * 255) 59 | .toString(16) 60 | .padStart(2, '0'); 61 | return hex + alpha; 62 | } 63 | 64 | // Main function to generate and save emoji colors 65 | async function main() { 66 | const filteredEmojis = filterEmojis(emojiData); 67 | const emojis = Object.keys(filteredEmojis); 68 | const colorMap: Record = {}; 69 | 70 | for (const emoji of emojis) { 71 | try { 72 | // Create SVG with emoji and convert to PNG buffer 73 | const svg = createEmojiSVG(emoji); 74 | const { dominant } = await sharp(Buffer.from(svg)).ensureAlpha().png().stats(); 75 | 76 | // Convert dominant color to hex with opacity 77 | colorMap[emoji] = rgbaToHex(dominant.r, dominant.g, dominant.b); 78 | } catch (error) { 79 | console.error(`Error processing emoji ${emoji}:`, error); 80 | } 81 | } 82 | 83 | // Save color map to file 84 | const outputPath = join(process.cwd(), 'src/utils/emojiColors.ts'); 85 | const fileContent = `// Generated file - do not edit 86 | export const emojiColors: Record = ${JSON.stringify(colorMap, null, 2)}; 87 | `; 88 | writeFileSync(outputPath, fileContent); 89 | console.log('Emoji colors generated successfully!'); 90 | } 91 | 92 | main().catch(console.error); 93 | 94 | const port = 3456; 95 | const url = `http://localhost:${port}`; 96 | 97 | console.log(`Starting server on ${url}`); 98 | 99 | // Function to open browser based on platform 100 | async function openBrowser(url: string) { 101 | const platform = process.platform; 102 | 103 | switch (platform) { 104 | case 'darwin': 105 | // macOS 106 | Bun.spawn(['open', url]); 107 | break; 108 | case 'win32': 109 | // Windows 110 | Bun.spawn(['cmd', '/c', 'start', url]); 111 | break; 112 | default: 113 | // Linux and others 114 | Bun.spawn(['xdg-open', url]); 115 | } 116 | } 117 | 118 | // Start server 119 | serve({ 120 | port, 121 | async fetch(req) { 122 | const url = new URL(req.url); 123 | 124 | // Serve the HTML file 125 | if (url.pathname === '/') { 126 | return new Response(Bun.file('scripts/generateEmojiColors.html')); 127 | } 128 | 129 | // Serve the emoji data 130 | if (url.pathname === '/emoji-data') { 131 | return new Response(JSON.stringify(filterEmojis(emojiData)), { 132 | headers: { 'Content-Type': 'application/json' }, 133 | }); 134 | } 135 | 136 | // Handle saving the results 137 | if (url.pathname === '/save' && req.method === 'POST') { 138 | const data = await req.text(); 139 | const outputPath = join(import.meta.dir, '../src/utils/emojiColors.ts'); 140 | writeFileSync(outputPath, data); 141 | return new Response('Colors saved successfully!'); 142 | } 143 | 144 | return new Response('Not found', { status: 404 }); 145 | }, 146 | }); 147 | 148 | // Open browser after a short delay to ensure server is ready 149 | setTimeout(() => { 150 | openBrowser(url); 151 | }, 500); 152 | -------------------------------------------------------------------------------- /packages/emoji-picker/scripts/release.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { execSync } from 'child_process'; 3 | 4 | const bumpVersion = (version: string, type: 'patch' | 'minor' | 'major'): string => { 5 | const [major, minor, patch] = version.replace('v', '').split('.').map(Number); 6 | switch (type) { 7 | case 'major': 8 | return `${major + 1}.0.0`; 9 | case 'minor': 10 | return `${major}.${minor + 1}.0`; 11 | case 'patch': 12 | return `${major}.${minor}.${patch + 1}`; 13 | } 14 | }; 15 | 16 | const main = async () => { 17 | const type = process.argv[2] as 'patch' | 'minor' | 'major'; 18 | if (!['patch', 'minor', 'major'].includes(type)) { 19 | console.error('Please specify version type: patch, minor, or major'); 20 | process.exit(1); 21 | } 22 | 23 | // Read current version from package.json 24 | const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); 25 | const currentVersion = pkg.version; 26 | const newVersion = bumpVersion(currentVersion, type); 27 | 28 | // Update package.json 29 | pkg.version = newVersion; 30 | writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); 31 | 32 | try { 33 | // Commit package.json changes 34 | execSync('git add package.json'); 35 | execSync(`git commit -m "chore: bump version to ${newVersion}"`); 36 | 37 | // Create and push tag 38 | execSync(`git tag v${newVersion}`); 39 | execSync('git push'); 40 | execSync('git push --tags'); 41 | 42 | console.log(`Successfully released version ${newVersion}`); 43 | } catch (error) { 44 | console.error('Error during release:', error); 45 | process.exit(1); 46 | } 47 | }; 48 | 49 | main().catch(console.error); 50 | -------------------------------------------------------------------------------- /packages/emoji-picker/scripts/test-react-versions.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { execSync } from 'child_process'; 3 | import { resolve } from 'path'; 4 | import { existsSync, mkdirSync, writeFileSync } from 'fs'; 5 | 6 | // React versions to test 7 | const REACT_VERSIONS = { 8 | '18.2.0': { reactDom: '18.2.0' }, 9 | '19.0.0': { reactDom: '19.0.0' }, 10 | }; 11 | 12 | const ROOT_DIR = resolve(__dirname, '..'); 13 | const TEST_DIR = resolve(ROOT_DIR, 'react-version-tests'); 14 | 15 | // Ensure test directory exists 16 | if (!existsSync(TEST_DIR)) { 17 | mkdirSync(TEST_DIR, { recursive: true }); 18 | } 19 | 20 | /** 21 | * Run tests with a specific React version 22 | */ 23 | function testWithReactVersion(version: string, reactDomVersion: string) { 24 | console.log(`\n\n========================================`); 25 | console.log(`Testing with React ${version}`); 26 | console.log(`========================================\n`); 27 | 28 | const testDir = resolve(TEST_DIR, `react-${version}`); 29 | 30 | // Create test directory if it doesn't exist 31 | if (!existsSync(testDir)) { 32 | mkdirSync(testDir, { recursive: true }); 33 | } 34 | 35 | // Create temporary package.json with specific React version 36 | const packageJson = { 37 | name: `emoji-picker-react-${version}-test`, 38 | version: '1.0.0', 39 | type: 'module', 40 | scripts: { 41 | test: 'bun test', 42 | }, 43 | dependencies: { 44 | '@ferrucc-io/emoji-picker': 'link:../..', 45 | react: `${version}`, 46 | 'react-dom': `${reactDomVersion}`, 47 | }, 48 | devDependencies: { 49 | '@testing-library/react': '^14.2.1', 50 | '@happy-dom/global-registrator': '^16.7.3', 51 | '@testing-library/jest-dom': '^6.4.2', 52 | }, 53 | }; 54 | 55 | // Write package.json 56 | writeFileSync(resolve(testDir, 'package.json'), JSON.stringify(packageJson, null, 2)); 57 | 58 | // Create test file 59 | const testFile = ` 60 | import { test, expect, beforeEach, afterEach } from 'bun:test'; 61 | import { render, cleanup } from '@testing-library/react'; 62 | import React from 'react'; 63 | import { EmojiPicker } from '@ferrucc-io/emoji-picker'; 64 | 65 | // Register happy-dom 66 | import { GlobalRegistrator } from '@happy-dom/global-registrator'; 67 | GlobalRegistrator.register(); 68 | 69 | // Import test matchers 70 | import * as matchers from '@testing-library/jest-dom/matchers'; 71 | expect.extend(matchers); 72 | 73 | // Setup and teardown 74 | beforeEach(() => { 75 | cleanup(); 76 | }); 77 | 78 | afterEach(() => { 79 | cleanup(); 80 | }); 81 | 82 | // Create a wrapper component to ensure proper React context 83 | const TestWrapper = ({ children }) => { 84 | return
{children}
; 85 | }; 86 | 87 | test('EmojiPicker renders with React ${version}', () => { 88 | // Test the wrapper first to verify React is working 89 | const { getByTestId } = render(
Test
); 90 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 91 | }); 92 | 93 | test('EmojiPicker handles basic interactions', async () => { 94 | // Just verify React rendering works first 95 | const { getByTestId } = render(); 96 | expect(getByTestId('test-wrapper')).toBeInTheDocument(); 97 | }); 98 | `; 99 | 100 | // Write test file 101 | writeFileSync(resolve(testDir, 'emojiPicker.test.tsx'), testFile); 102 | 103 | try { 104 | // Install dependencies and run tests 105 | console.log(`Installing dependencies for React ${version}...`); 106 | execSync('bun install', { cwd: testDir, stdio: 'inherit' }); 107 | 108 | console.log(`Running tests with React ${version}...`); 109 | execSync('bun test', { cwd: testDir, stdio: 'inherit' }); 110 | 111 | console.log(`\n✅ Tests passed with React ${version}\n`); 112 | return true; 113 | } catch (error) { 114 | console.error(`\n❌ Tests failed with React ${version}\n`); 115 | console.error(error); 116 | return false; 117 | } 118 | } 119 | 120 | async function main() { 121 | console.log('Starting React version compatibility tests...'); 122 | 123 | const results: Record = {}; 124 | 125 | for (const [reactVersion, { reactDom }] of Object.entries(REACT_VERSIONS)) { 126 | results[reactVersion] = testWithReactVersion(reactVersion, reactDom); 127 | } 128 | 129 | console.log('\n========================================'); 130 | console.log('React Version Compatibility Test Results:'); 131 | console.log('========================================'); 132 | 133 | for (const [version, passed] of Object.entries(results)) { 134 | console.log(`React ${version}: ${passed ? '✅ PASSED' : '❌ FAILED'}`); 135 | } 136 | } 137 | 138 | main().catch(console.error); 139 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiCategories.tsx: -------------------------------------------------------------------------------- 1 | import emojiData from 'unicode-emoji-json/data-by-group.json'; 2 | import React, { useMemo, useRef } from 'react'; 3 | import { useAtomValue } from 'jotai'; 4 | import { EmojiPickerListHeader } from './EmojiPickerListHeader'; 5 | import { useEmojiPicker } from './EmojiPickerContext'; 6 | import { EmojiPickerButton } from './EmojiPickerButton'; 7 | import { filterSupportedEmojis } from '../utils/supportedEmojis'; 8 | import { applySkinTone } from '../utils/applySkinTone'; 9 | import { useVirtualizedList } from '../hooks/useVirtualizedList'; 10 | import { useEmojiKeyboardNavigation } from '../hooks/useEmojiKeyboardNavigation'; 11 | import { skinToneAtom } from '../atoms/emoji'; 12 | 13 | import type { EmojiGroup, EmojiMetadata } from '../utils/supportedEmojis'; 14 | type Row = { type: 'header'; content: string } | { type: 'emojis'; content: EmojiMetadata[] }; 15 | 16 | interface EmojiCategoriesProps { 17 | hideStickyHeader?: boolean; 18 | containerHeight?: number; 19 | } 20 | 21 | const emojiCategories = filterSupportedEmojis(emojiData as EmojiGroup[]); 22 | 23 | function EmojiCategoriesBase({ 24 | hideStickyHeader = false, 25 | containerHeight = 364, 26 | }: EmojiCategoriesProps) { 27 | const { emojisPerRow, emojiSize } = useEmojiPicker(); 28 | const skinTone = useAtomValue(skinToneAtom); 29 | 30 | const parentRef = useRef(null); 31 | 32 | const rows = useMemo(() => { 33 | return emojiCategories.flatMap((category) => { 34 | const rows: Row[] = []; 35 | 36 | rows.push({ 37 | type: 'header', 38 | content: category.category, 39 | }); 40 | 41 | for (let i = 0; i < category.emojis.length; i += emojisPerRow) { 42 | rows.push({ 43 | type: 'emojis', 44 | content: category.emojis 45 | .slice(i, i + emojisPerRow) 46 | .map((emoji) => applySkinTone(emoji, skinTone)), 47 | }); 48 | } 49 | return rows; 50 | }); 51 | }, [emojisPerRow, skinTone]); 52 | 53 | const { virtualizer, isSticky, isActiveSticky } = useVirtualizedList({ 54 | rows, 55 | getScrollElement: () => parentRef.current, 56 | estimateSize: (index) => { 57 | const row = rows[index]; 58 | return row?.type === 'header' ? 32 : emojiSize; 59 | }, 60 | isHeader: (row: Row) => row.type === 'header', 61 | hideStickyHeader, 62 | }); 63 | 64 | useEmojiKeyboardNavigation({ rows, virtualizer }); 65 | 66 | return ( 67 |
75 |
82 | {virtualizer.getVirtualItems().map((virtualRow) => { 83 | const row = rows[virtualRow.index]; 84 | 85 | return ( 86 |
109 | {row.type === 'header' ? ( 110 | 111 | ) : ( 112 |
116 | {row.content.map((emojiData, index) => ( 117 | 124 | ))} 125 |
126 | )} 127 |
128 | ); 129 | })} 130 |
131 |
132 | ); 133 | } 134 | 135 | export const EmojiCategories = React.memo(EmojiCategoriesBase); 136 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Provider } from 'jotai'; 3 | import { EmojiPickerSkinTone } from './EmojiPickerSkinTone'; 4 | import { EmojiPickerPreview } from './EmojiPickerPreview'; 5 | import { EmojiPickerList } from './EmojiPickerList'; 6 | import { EmojiPickerInput } from './EmojiPickerInput'; 7 | import { EmojiPickerGroup } from './EmojiPickerGroup'; 8 | import { EmojiPickerProvider } from './EmojiPickerContext'; 9 | import { EmojiPickerContent } from './EmojiPickerContent'; 10 | import { cn } from '../utils/cn'; 11 | 12 | export interface EmojiPickerProps { 13 | children?: React.ReactNode; 14 | className?: string; 15 | onEmojiSelect?: (emoji: string) => void; 16 | emojisPerRow?: number; 17 | emojiSize?: number; 18 | maxUnicodeVersion?: number; 19 | } 20 | 21 | interface EmojiPickerHeaderProps { 22 | children?: React.ReactNode; 23 | className?: string; 24 | } 25 | 26 | function Header({ children, className = '' }: EmojiPickerHeaderProps) { 27 | return ( 28 |
{children}
29 | ); 30 | } 31 | 32 | function Input(props: React.ComponentProps) { 33 | return ; 34 | } 35 | 36 | export function EmojiPicker({ 37 | children, 38 | className = '', 39 | onEmojiSelect, 40 | emojisPerRow = 12, 41 | emojiSize = 28, 42 | maxUnicodeVersion = 15.0, 43 | }: EmojiPickerProps) { 44 | const [localSelectedEmoji, setLocalSelectedEmoji] = useState(null); 45 | 46 | useEffect(() => { 47 | if (localSelectedEmoji && onEmojiSelect) { 48 | onEmojiSelect(localSelectedEmoji); 49 | } 50 | }, [localSelectedEmoji, onEmojiSelect]); 51 | 52 | return ( 53 | 54 | 60 |
67 | {children} 68 |
69 |
70 |
71 | ); 72 | } 73 | 74 | EmojiPicker.Header = Header; 75 | EmojiPicker.Input = Input; 76 | EmojiPicker.Group = EmojiPickerGroup; 77 | EmojiPicker.List = EmojiPickerList; 78 | EmojiPicker.Preview = EmojiPickerPreview; 79 | EmojiPicker.Content = EmojiPickerContent; 80 | EmojiPicker.SkinTone = EmojiPickerSkinTone; 81 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from 'react'; 2 | import { useAtomValue, useSetAtom } from 'jotai'; 3 | import { useEmojiPicker } from './EmojiPickerContext'; 4 | import { emojiColors } from '../utils/emojiColors'; 5 | import { applySkinTone } from '../utils/applySkinTone'; 6 | import { 7 | hoveredEmojiAtom, 8 | isEmojiSelectedAtom, 9 | selectedPositionAtom, 10 | skinToneOnlyAtom, 11 | } from '../atoms/emoji'; 12 | 13 | interface EmojiPickerButtonProps { 14 | emoji: { 15 | emoji: string; 16 | name: string; 17 | slug: string; 18 | skin_tone_support: boolean; 19 | }; 20 | rowIndex: number; 21 | columnIndex: number; 22 | size?: number; 23 | } 24 | 25 | function buttonPropsAreEqual(prevProps: EmojiPickerButtonProps, nextProps: EmojiPickerButtonProps) { 26 | return ( 27 | prevProps.emoji.emoji === nextProps.emoji.emoji && 28 | prevProps.rowIndex === nextProps.rowIndex && 29 | prevProps.columnIndex === nextProps.columnIndex && 30 | prevProps.size === nextProps.size 31 | ); 32 | } 33 | 34 | const EmojiPickerButtonBase = React.memo(function EmojiPickerButtonBase({ 35 | emoji, 36 | rowIndex, 37 | columnIndex, 38 | size = 28, 39 | }: EmojiPickerButtonProps) { 40 | const setHoveredEmoji = useSetAtom(hoveredEmojiAtom); 41 | const setSelectedPosition = useSetAtom(selectedPositionAtom); 42 | const skinTone = useAtomValue(skinToneOnlyAtom); 43 | const { onEmojiSelect } = useEmojiPicker(); 44 | 45 | const selectedAtom = useMemo( 46 | () => isEmojiSelectedAtom(rowIndex, columnIndex), 47 | [rowIndex, columnIndex] 48 | ); 49 | const isSelected = useAtomValue(selectedAtom); 50 | 51 | // Only apply skin tone if supported and memoize the result 52 | const emojiWithSkinTone = useMemo( 53 | () => (emoji.skin_tone_support ? applySkinTone(emoji, skinTone) : emoji), 54 | [emoji, skinTone] 55 | ); 56 | 57 | // Memoize hover color calculation 58 | const hoverColor = useMemo( 59 | () => emojiColors[emojiWithSkinTone.emoji] || 'var(--fallback-hover-color, rgba(0, 0, 0, 0.1))', 60 | [emojiWithSkinTone.emoji] 61 | ); 62 | 63 | const handleMouseEnter = useCallback(() => { 64 | setHoveredEmoji(emoji); 65 | }, [emoji, setHoveredEmoji]); 66 | 67 | const handleMouseLeave = useCallback(() => { 68 | setHoveredEmoji(null); 69 | }, [setHoveredEmoji]); 70 | 71 | const handleClick = useCallback(() => { 72 | setSelectedPosition({ row: rowIndex, column: columnIndex }); 73 | if (onEmojiSelect) { 74 | onEmojiSelect(emojiWithSkinTone.emoji); 75 | } 76 | }, [emojiWithSkinTone.emoji, rowIndex, columnIndex, setSelectedPosition, onEmojiSelect]); 77 | 78 | // Memoize the button style to prevent recalculation 79 | const buttonStyle = useMemo( 80 | () => 81 | ({ 82 | '--emoji-hover-color': hoverColor, 83 | width: `${size}px`, 84 | height: `${size}px`, 85 | fontSize: `${Math.floor(size * 0.7)}px`, 86 | }) as React.CSSProperties, 87 | [hoverColor, size] 88 | ); 89 | 90 | return ( 91 | 106 | ); 107 | }, buttonPropsAreEqual); 108 | 109 | export const EmojiPickerButton = EmojiPickerButtonBase; 110 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAtom } from 'jotai'; 3 | import { useEmojiPicker } from './EmojiPickerContext'; 4 | import { hoveredEmojiAtom } from '../atoms/emoji'; 5 | 6 | export function EmojiPickerContent() { 7 | const { emojiSize } = useEmojiPicker(); 8 | const [hoveredEmoji] = useAtom(hoveredEmojiAtom); 9 | 10 | if (!hoveredEmoji) { 11 | return null; 12 | } 13 | 14 | return ( 15 |
16 | 17 | {hoveredEmoji.emoji} 18 | 19 |
20 | 21 | {hoveredEmoji.name} 22 | 23 | 24 | :{hoveredEmoji.slug}: 25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo } from 'react'; 2 | 3 | interface EmojiPickerContextType { 4 | emojisPerRow: number; 5 | emojiSize: number; 6 | maxUnicodeVersion: number; 7 | onEmojiSelect?: (emoji: string) => void; 8 | } 9 | 10 | const EmojiPickerContext = createContext(null); 11 | 12 | const useEmojiPicker = () => { 13 | const context = useContext(EmojiPickerContext); 14 | if (!context) { 15 | throw new Error('useEmojiPicker must be used within an EmojiPickerProvider'); 16 | } 17 | return context; 18 | }; 19 | 20 | interface EmojiPickerProviderProps { 21 | children: React.ReactNode; 22 | emojisPerRow?: number; 23 | emojiSize?: number; 24 | maxUnicodeVersion: number; 25 | onEmojiSelect?: (emoji: string) => void; 26 | } 27 | 28 | export function EmojiPickerProvider({ 29 | children, 30 | emojisPerRow = 8, 31 | emojiSize = 32, 32 | maxUnicodeVersion = 15.0, 33 | onEmojiSelect, 34 | }: EmojiPickerProviderProps) { 35 | const value = useMemo( 36 | () => ({ 37 | emojisPerRow, 38 | emojiSize, 39 | maxUnicodeVersion, 40 | onEmojiSelect, 41 | }), 42 | [emojisPerRow, emojiSize, maxUnicodeVersion, onEmojiSelect] 43 | ); 44 | 45 | return {children}; 46 | } 47 | 48 | export { useEmojiPicker }; 49 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerEmpty.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface EmojiPickerEmptyProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function EmojiPickerEmpty({ children }: EmojiPickerEmptyProps) { 8 | return ( 9 |
{children}
10 | ); 11 | } 12 | 13 | export function EmojiPickerEmptyIcon() { 14 | return ( 15 |
16 | 24 | 29 | 30 |
31 | ); 32 | } 33 | 34 | export function EmojiPickerEmptyText() { 35 | return
No emojis found
; 36 | } 37 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../utils/cn'; 3 | 4 | export interface EmojiPickerGroupProps { 5 | children: React.ReactNode; 6 | title?: string; 7 | className?: string; 8 | } 9 | 10 | export function EmojiPickerGroup({ children, className }: EmojiPickerGroupProps) { 11 | return ( 12 |
13 |
16 | {children} 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { useAtom } from 'jotai'; 3 | import { ClearIcon, SearchIcon } from './icons'; 4 | import { cn } from '../utils/cn'; 5 | import { searchAtom } from '../atoms/emoji'; 6 | 7 | import type { ReactNode } from 'react'; 8 | interface EmojiPickerInputProps 9 | extends Omit, 'onChange'> { 10 | placeholder?: string; 11 | endIcon?: ReactNode; 12 | onClear?: () => void; 13 | className?: string; 14 | hideIcon?: boolean; 15 | autoFocus?: boolean; 16 | } 17 | 18 | export const EmojiPickerInput = forwardRef( 19 | ( 20 | { placeholder, endIcon, onClear, className, hideIcon = false, autoFocus = false, ...props }, 21 | ref 22 | ) => { 23 | const [search, setSearch] = useAtom(searchAtom); 24 | 25 | const handleClear = () => { 26 | setSearch(''); 27 | onClear?.(); 28 | }; 29 | 30 | const handleKeyDown = (e: React.KeyboardEvent) => { 31 | if (e.key === 'Escape') { 32 | handleClear(); 33 | } 34 | }; 35 | 36 | return ( 37 |
38 | {!hideIcon && ( 39 |
40 | 41 |
42 | )} 43 | setSearch(e.target.value)} 48 | onKeyDown={handleKeyDown} 49 | type="text" 50 | autoFocus={autoFocus} 51 | placeholder={placeholder || 'Search emoji'} 52 | className={cn( 53 | 'h-7 w-full bg-zinc-100 dark:bg-zinc-800 rounded-md text-sm', 54 | 'text-zinc-900 dark:text-zinc-100', 55 | 'placeholder:text-zinc-500 dark:placeholder:text-zinc-400', 56 | 'focus:outline-none focus:ring-1 focus:ring-indigo-500', 57 | !hideIcon && 'pl-7', 58 | hideIcon && 'pl-2', 59 | endIcon || search ? 'pr-7' : 'pr-2', 60 | className 61 | )} 62 | /> 63 | {search && ( 64 |
65 | {endIcon ? ( 66 | 69 | ) : ( 70 | 73 | )} 74 |
75 | )} 76 |
77 | ); 78 | } 79 | ); 80 | 81 | EmojiPickerInput.displayName = 'EmojiPickerInput'; 82 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAtomValue } from 'jotai'; 3 | import { EmojiSearchResults } from './EmojiSearchResults'; 4 | import { EmojiPickerListHeader } from './EmojiPickerListHeader'; 5 | import { EmojiPickerEmpty, EmojiPickerEmptyIcon, EmojiPickerEmptyText } from './EmojiPickerEmpty'; 6 | import { useEmojiPicker } from './EmojiPickerContext'; 7 | import { EmojiCategories } from './EmojiCategories'; 8 | import { filteredEmojisAtom, searchAtom } from '../atoms/emoji'; 9 | 10 | export interface EmojiPickerListProps { 11 | hideStickyHeader?: boolean; 12 | containerHeight?: number; 13 | } 14 | 15 | function EmojiPickerListBase({ 16 | hideStickyHeader = false, 17 | containerHeight = 364, 18 | }: EmojiPickerListProps) { 19 | const { emojiSize } = useEmojiPicker(); 20 | const search = useAtomValue(searchAtom); 21 | const filteredEmojis = useAtomValue(filteredEmojisAtom); 22 | 23 | const content = search.trim() ? ( 24 | filteredEmojis.length === 0 ? ( 25 |
26 | 27 | 28 | 29 | 30 | 31 |
32 | ) : ( 33 | 34 | ) 35 | ) : ( 36 | 37 | ); 38 | 39 | return content; 40 | } 41 | 42 | function propsAreEqual(prevProps: EmojiPickerListProps, nextProps: EmojiPickerListProps) { 43 | return ( 44 | prevProps.hideStickyHeader === nextProps.hideStickyHeader && 45 | prevProps.containerHeight === nextProps.containerHeight 46 | ); 47 | } 48 | 49 | export const EmojiPickerList = React.memo(EmojiPickerListBase, propsAreEqual); 50 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerListHeader.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/cn'; 2 | 3 | interface EmojiPickerListHeaderProps { 4 | content: string; 5 | emojiSize: number; 6 | className?: string; 7 | } 8 | 9 | export function EmojiPickerListHeader({ 10 | content, 11 | emojiSize, 12 | className = '', 13 | }: EmojiPickerListHeaderProps) { 14 | const textSize = emojiSize > 32 ? 'text-sm' : 'text-xs'; 15 | 16 | return ( 17 |
18 |
26 | {content} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useAtom, useAtomValue } from 'jotai'; 3 | import { useEmojiPicker } from './EmojiPickerContext'; 4 | import { cn } from '../utils/cn'; 5 | import { applySkinTone } from '../utils/applySkinTone'; 6 | import { hoveredEmojiAtom, skinToneAtom } from '../atoms/emoji'; 7 | 8 | import type { EmojiMetadata } from '../types/emoji'; 9 | export interface EmojiPickerPreviewProps { 10 | children: (props: { previewedEmoji: EmojiMetadata | null }) => React.ReactNode; 11 | className?: string; 12 | } 13 | 14 | export const EmojiPickerPreview = React.memo(function EmojiPickerPreview({ 15 | children, 16 | className, 17 | }: EmojiPickerPreviewProps) { 18 | const { emojiSize } = useEmojiPicker(); 19 | const [hoveredEmoji] = useAtom(hoveredEmojiAtom); 20 | const skinTone = useAtomValue(skinToneAtom); 21 | 22 | const previewedEmoji = useMemo( 23 | () => (hoveredEmoji ? applySkinTone(hoveredEmoji, skinTone) : null), 24 | [hoveredEmoji, skinTone] 25 | ); 26 | 27 | const containerHeight = Math.max(emojiSize * 1.1, 44); // Minimum height of 44px for better UX 28 | 29 | return ( 30 |
36 |
40 | {children({ previewedEmoji })} 41 |
42 |
43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiPickerSkinTone.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useAtom } from 'jotai'; 3 | import { skinToneAtom } from '../atoms/emoji'; 4 | 5 | import type { SkinTone } from '../types/emoji'; 6 | 7 | const skinTones: Array<{ emoji: string; tone: SkinTone }> = [ 8 | { emoji: '✋', tone: 'default' }, 9 | { emoji: '✋🏻', tone: 'light' }, 10 | { emoji: '✋🏼', tone: 'medium-light' }, 11 | { emoji: '✋🏽', tone: 'medium' }, 12 | { emoji: '✋🏾', tone: 'medium-dark' }, 13 | { emoji: '✋🏿', tone: 'dark' }, 14 | ]; 15 | 16 | export function EmojiPickerSkinTone() { 17 | const [isPickerOpen, setIsPickerOpen] = useState(false); 18 | const [skinTone, setSkinTone] = useAtom(skinToneAtom); 19 | 20 | const currentTone = skinTones.find((t) => t.tone === skinTone) || skinTones[0]; 21 | 22 | if (isPickerOpen) { 23 | return ( 24 |
25 |
26 | {skinTones.map((tone) => ( 27 | 37 | ))} 38 |
39 |
40 | Choose your default skin tone 41 |
42 |
43 | ); 44 | } 45 | 46 | return ( 47 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/EmojiSearchResults.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from 'react'; 2 | import { useAtomValue } from 'jotai'; 3 | import { EmojiPickerListHeader } from './EmojiPickerListHeader'; 4 | import { useEmojiPicker } from './EmojiPickerContext'; 5 | import { EmojiPickerButton } from './EmojiPickerButton'; 6 | import { useVirtualizedList } from '../hooks/useVirtualizedList'; 7 | import { useEmojiKeyboardNavigation } from '../hooks/useEmojiKeyboardNavigation'; 8 | import { filteredEmojisAtom } from '../atoms/emoji'; 9 | 10 | import type { EmojiMetadata } from '../types/emoji'; 11 | type Row = { type: 'header'; content: string } | { type: 'emojis'; content: EmojiMetadata[] }; 12 | 13 | interface EmojiSearchResultsProps { 14 | hideStickyHeader?: boolean; 15 | containerHeight?: number; 16 | } 17 | 18 | export function EmojiSearchResults({ 19 | hideStickyHeader = false, 20 | containerHeight = 364, 21 | }: EmojiSearchResultsProps) { 22 | const { emojisPerRow, emojiSize } = useEmojiPicker(); 23 | 24 | const filteredEmojis = useAtomValue(filteredEmojisAtom); 25 | 26 | const parentRef = useRef(null); 27 | 28 | // Create rows from search results 29 | const rows = useMemo(() => { 30 | const searchResults = filteredEmojis.flatMap((category) => category.emojis); 31 | const rows: Row[] = []; 32 | 33 | // Add single "Search results" header 34 | rows.push({ 35 | type: 'header', 36 | content: 'Search results', 37 | }); 38 | 39 | // Add emoji rows 40 | for (let i = 0; i < searchResults.length; i += emojisPerRow) { 41 | rows.push({ 42 | type: 'emojis', 43 | content: searchResults.slice(i, i + emojisPerRow), 44 | }); 45 | } 46 | 47 | return rows; 48 | }, [filteredEmojis, emojisPerRow]); 49 | 50 | const { virtualizer, isSticky, isActiveSticky } = useVirtualizedList({ 51 | rows, 52 | getScrollElement: () => parentRef.current, 53 | estimateSize: (index) => { 54 | const row = rows[index]; 55 | return row?.type === 'header' ? 32 : emojiSize; 56 | }, 57 | isHeader: (row: Row) => row.type === 'header', 58 | hideStickyHeader, 59 | }); 60 | 61 | useEmojiKeyboardNavigation({ rows, virtualizer }); 62 | 63 | return ( 64 |
72 |
79 | {virtualizer.getVirtualItems().map((virtualRow) => { 80 | const row = rows[virtualRow.index]; 81 | 82 | return ( 83 |
106 | {row.type === 'header' ? ( 107 | 108 | ) : ( 109 |
113 | {row.content.map((emojiData, index) => ( 114 | 121 | ))} 122 |
123 | )} 124 |
125 | ); 126 | })} 127 |
128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/__tests__/EmojiCategories.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { atom, createStore, Provider } from 'jotai'; 3 | import { beforeEach, describe, expect, mock, test } from 'bun:test'; 4 | import { render } from '@testing-library/react'; 5 | import { mockEmojiData } from './testData'; 6 | import { EmojiPickerProvider } from '../EmojiPickerContext'; 7 | import { EmojiCategories } from '../EmojiCategories'; 8 | 9 | // Mock the emoji data module 10 | mock.module('unicode-emoji-json/data-by-group.json', () => mockEmojiData); 11 | 12 | // Mock the virtualizer 13 | const mockVirtualizer = { 14 | getVirtualItems: () => [ 15 | { index: 0, start: 0, size: 32, key: '0', measureElement: null }, 16 | { index: 1, start: 32, size: 32, key: '1', measureElement: null }, 17 | { index: 2, start: 64, size: 32, key: '2', measureElement: null }, 18 | { index: 3, start: 96, size: 32, key: '3', measureElement: null }, 19 | ], 20 | getTotalSize: () => 128, 21 | scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => {}, 22 | }; 23 | 24 | mock.module('@tanstack/react-virtual', () => ({ 25 | useVirtualizer: () => mockVirtualizer, 26 | defaultRangeExtractor: (range: any) => range, 27 | })); 28 | 29 | // Mock data 30 | const mockFilteredEmojis = [ 31 | { 32 | category: 'Smileys & Emotion', 33 | emojis: [ 34 | { 35 | emoji: '😀', 36 | name: 'grinning face', 37 | slug: 'grinning-face', 38 | skin_tone_support: false, 39 | }, 40 | { 41 | emoji: '😃', 42 | name: 'grinning face with big eyes', 43 | slug: 'grinning-face-with-big-eyes', 44 | skin_tone_support: false, 45 | }, 46 | ], 47 | }, 48 | { 49 | category: 'People & Body', 50 | emojis: [ 51 | { 52 | emoji: '✋', 53 | name: 'raised hand', 54 | slug: 'raised-hand', 55 | skin_tone_support: true, 56 | }, 57 | { 58 | emoji: '👋', 59 | name: 'waving hand', 60 | slug: 'waving-hand', 61 | skin_tone_support: true, 62 | }, 63 | ], 64 | }, 65 | ]; 66 | 67 | const testFilteredEmojisAtom = atom(mockFilteredEmojis); 68 | const testSkinToneAtom = atom('medium-dark'); 69 | 70 | // Mock the emoji atoms module 71 | mock.module('../atoms/emoji', () => ({ 72 | filteredEmojisAtom: testFilteredEmojisAtom, 73 | skinToneAtom: testSkinToneAtom, 74 | skinToneOnlyAtom: atom((get) => get(testSkinToneAtom)), 75 | hoveredEmojiAtom: atom(null), 76 | selectedEmojiAtom: atom(null), 77 | selectedPositionAtom: atom(null), 78 | })); 79 | 80 | describe('EmojiCategories', () => { 81 | const store = createStore(); 82 | 83 | const renderWithProviders = (ui: React.ReactNode) => { 84 | store.set(testFilteredEmojisAtom, mockFilteredEmojis); 85 | store.set(testSkinToneAtom, 'medium-dark'); 86 | 87 | return render( 88 | 89 | 90 | {ui} 91 | 92 | 93 | ); 94 | }; 95 | 96 | beforeEach(() => { 97 | store.set(testSkinToneAtom, 'medium-dark'); 98 | }); 99 | 100 | test('renders category headers and emoji buttons', () => { 101 | const { container } = renderWithProviders(); 102 | 103 | // Should find category headers (we have 2 categories in mockFilteredEmojis) 104 | const headers = container.querySelectorAll('[data-testid="emoji-picker-list-header"]'); 105 | expect(headers.length).toBe(1); 106 | 107 | // Should find emoji buttons 108 | const emojiButtons = container.querySelectorAll('button'); 109 | expect(emojiButtons.length).toBeGreaterThan(0); 110 | }); 111 | 112 | test('respects emojisPerRow from context', () => { 113 | const { container } = renderWithProviders(); 114 | 115 | // Find emoji rows 116 | const emojiRows = container.querySelectorAll('[data-type="emojis"]'); 117 | emojiRows.forEach((row) => { 118 | const buttons = row.querySelectorAll('button'); 119 | expect(buttons.length).toBeLessThanOrEqual(8); // 8 is the emojisPerRow value from context 120 | }); 121 | }); 122 | 123 | test('respects containerHeight prop', () => { 124 | const customHeight = 500; 125 | const { container } = renderWithProviders(); 126 | 127 | const scrollContainer = container.querySelector('.overflow-y-auto'); 128 | expect(scrollContainer).toBeDefined(); 129 | expect(scrollContainer?.getAttribute('style')).toInclude(`height: ${customHeight}px`); 130 | }); 131 | 132 | test('handles hideStickyHeader prop', () => { 133 | const { container } = renderWithProviders(); 134 | 135 | const headers = container.querySelectorAll('[data-type="header"]'); 136 | headers.forEach((header) => { 137 | const parentStyle = window.getComputedStyle(header.parentElement!); 138 | expect(parentStyle.position).not.toBe('sticky'); 139 | }); 140 | }); 141 | 142 | test('renders with keyboard navigation support', () => { 143 | const { container } = renderWithProviders(); 144 | 145 | const scrollContainer = container.querySelector('.overflow-y-auto'); 146 | expect(scrollContainer?.getAttribute('tabIndex')).toBe('0'); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/__tests__/EmojiPickerList.test.tsx: -------------------------------------------------------------------------------- 1 | import { atom, createStore, Provider } from 'jotai'; 2 | import { beforeEach, describe, expect, mock, test } from 'bun:test'; 3 | import { render } from '@testing-library/react'; 4 | import { mockEmojiData, mockFilteredEmojis } from './testData'; 5 | import { EmojiPickerList } from '../EmojiPickerList'; 6 | import { EmojiPickerProvider } from '../EmojiPickerContext'; 7 | 8 | // Create writable test atoms 9 | const testSearchAtom = atom(''); 10 | 11 | // Mock the virtualizer 12 | const mockVirtualizer = { 13 | getVirtualItems: () => [ 14 | { index: 0, start: 0, size: 32, key: '0', measureElement: null }, 15 | { index: 1, start: 32, size: 32, key: '1', measureElement: null }, 16 | ], 17 | getTotalSize: () => 64, 18 | scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => {}, 19 | }; 20 | 21 | // Mock the modules 22 | mock.module('@tanstack/react-virtual', () => ({ 23 | useVirtualizer: () => mockVirtualizer, 24 | defaultRangeExtractor: (range: any) => range, 25 | })); 26 | mock.module('unicode-emoji-json/data-by-group.json', () => mockEmojiData); 27 | mock.module('../../utils/supportedEmojis', () => ({ 28 | filterSupportedEmojis: () => mockFilteredEmojis, 29 | })); 30 | mock.module('../../atoms/emoji', () => ({ 31 | searchAtom: testSearchAtom, 32 | filteredEmojisAtom: atom((get) => { 33 | const search = get(testSearchAtom); 34 | if (!search.trim()) { 35 | return mockFilteredEmojis; 36 | } 37 | return search === 'xyznotfound' ? [] : mockFilteredEmojis; 38 | }), 39 | skinToneAtom: atom('default'), 40 | skinToneOnlyAtom: atom('default'), 41 | hoveredEmojiAtom: atom(null), 42 | selectedEmojiAtom: atom(null), 43 | selectedPositionAtom: atom(null), 44 | })); 45 | 46 | describe('EmojiPickerList', () => { 47 | const store = createStore(); 48 | 49 | const renderWithProviders = (ui: React.ReactNode) => { 50 | return render( 51 | 52 | 53 | {ui} 54 | 55 | 56 | ); 57 | }; 58 | 59 | beforeEach(() => { 60 | store.set(testSearchAtom, ''); 61 | }); 62 | 63 | test('renders EmojiCategories by default when no search', () => { 64 | store.set(testSearchAtom, ''); 65 | const { container } = renderWithProviders(); 66 | 67 | // Should find at least one category header 68 | const headers = container.querySelectorAll('[data-testid="emoji-picker-list-header"]'); 69 | expect(headers.length).toBeGreaterThan(0); 70 | }); 71 | 72 | test('renders empty state when no search results found', () => { 73 | store.set(testSearchAtom, 'xyznotfound'); 74 | const { container } = renderWithProviders(); 75 | 76 | // Should find the empty state message 77 | expect(container.textContent).toInclude('No emojis found'); 78 | }); 79 | 80 | test('respects hideStickyHeader prop', () => { 81 | store.set(testSearchAtom, ''); 82 | const { container } = renderWithProviders(); 83 | 84 | // Headers should not have sticky positioning 85 | const headers = container.querySelectorAll('[data-testid="emoji-picker-list-header"]'); 86 | headers.forEach((header) => { 87 | const parentStyle = window.getComputedStyle(header.parentElement!); 88 | expect(parentStyle.position).not.toBe('sticky'); 89 | }); 90 | }); 91 | 92 | test('respects containerHeight prop', () => { 93 | const customHeight = 500; 94 | const { container } = renderWithProviders(); 95 | 96 | // Find the scrollable container 97 | const scrollContainer = container.querySelector('.overflow-y-auto'); 98 | expect(scrollContainer).toBeDefined(); 99 | expect(scrollContainer?.getAttribute('style')).toInclude(`height: ${customHeight}px`); 100 | }); 101 | 102 | test('renders with default containerHeight when not specified', () => { 103 | const { container } = renderWithProviders(); 104 | 105 | // Find the scrollable container 106 | const scrollContainer = container.querySelector('.overflow-y-auto'); 107 | expect(scrollContainer).toBeDefined(); 108 | expect(scrollContainer?.getAttribute('style')).toInclude('height: 364px'); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/__tests__/EmojiPickerListHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test'; 2 | import { render } from '@testing-library/react'; 3 | import { EmojiPickerListHeader } from '../EmojiPickerListHeader'; 4 | 5 | describe('EmojiPickerListHeader', () => { 6 | test('renders with small emoji size (≤ 32)', () => { 7 | const { container } = render(); 8 | const headerContainer = container.firstChild as HTMLElement; 9 | const textElement = headerContainer.firstChild as HTMLElement; 10 | expect(textElement.className).toInclude('text-xs'); 11 | expect(textElement.className).not.toInclude('text-sm'); 12 | expect(textElement.textContent).toBe('Small Header'); 13 | }); 14 | 15 | test('renders with large emoji size (> 32)', () => { 16 | const { container } = render(); 17 | const headerContainer = container.firstChild as HTMLElement; 18 | const textElement = headerContainer.firstChild as HTMLElement; 19 | expect(textElement.className).toInclude('text-sm'); 20 | expect(textElement.className).not.toInclude('text-xs'); 21 | expect(textElement.textContent).toBe('Large Header'); 22 | }); 23 | 24 | test('applies correct styling classes', () => { 25 | const { container } = render(); 26 | 27 | const headerContainer = container.firstChild as HTMLElement; 28 | 29 | const expectedClasses = [ 30 | 'relative', 31 | 'bg-white/90', 32 | 'dark:bg-zinc-950/90', 33 | 'supports-[backdrop-filter]:bg-white/50', 34 | 'supports-[backdrop-filter]:dark:bg-zinc-950/50', 35 | 'supports-[backdrop-filter]:backdrop-blur-sm', 36 | ]; 37 | 38 | expectedClasses.forEach((className) => { 39 | expect(headerContainer.className).toInclude(className); 40 | }); 41 | }); 42 | 43 | test('renders content correctly', () => { 44 | const { container } = render(); 45 | const headerContainer = container.firstChild as HTMLElement; 46 | const textElement = headerContainer.firstChild as HTMLElement; 47 | expect(textElement.textContent).toBe('Test Content'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/__tests__/EmojiSearchResults.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { atom, createStore, Provider } from 'jotai'; 3 | import { describe, expect, mock, test } from 'bun:test'; 4 | import { render } from '@testing-library/react'; 5 | import { EmojiSearchResults } from '../EmojiSearchResults'; 6 | import { EmojiPickerProvider } from '../EmojiPickerContext'; 7 | 8 | import type { EmojiMetadata } from '../../types/emoji'; 9 | 10 | // Mock the virtualizer 11 | const mockVirtualizer = { 12 | getVirtualItems: () => [ 13 | { index: 0, start: 0, size: 32, key: '0', measureElement: null }, 14 | { index: 1, start: 32, size: 32, key: '1', measureElement: null }, 15 | ], 16 | getTotalSize: () => 64, 17 | scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => {}, 18 | }; 19 | 20 | mock.module('@tanstack/react-virtual', () => ({ 21 | useVirtualizer: () => mockVirtualizer, 22 | defaultRangeExtractor: (range: any) => range, 23 | })); 24 | 25 | // Mock data 26 | const mockFilteredEmojis = [ 27 | { 28 | category: 'Smileys & Emotion', 29 | emojis: [ 30 | { 31 | emoji: '😀', 32 | name: 'grinning face', 33 | slug: 'grinning-face', 34 | skin_tone_support: false, 35 | }, 36 | { 37 | emoji: '😃', 38 | name: 'grinning face with big eyes', 39 | slug: 'grinning-face-with-big-eyes', 40 | skin_tone_support: false, 41 | }, 42 | ], 43 | }, 44 | ]; 45 | 46 | // Create writable test atoms 47 | const testFilteredEmojisAtom = atom(mockFilteredEmojis); 48 | 49 | // Mock the filteredEmojisAtom module 50 | mock.module('../atoms/emoji', () => ({ 51 | filteredEmojisAtom: testFilteredEmojisAtom, 52 | hoveredEmojiAtom: atom(null), 53 | selectedEmojiAtom: atom(null), 54 | selectedPositionAtom: atom(null), 55 | })); 56 | 57 | const renderWithProviders = (component: React.ReactElement) => { 58 | const store = createStore(); 59 | store.set(testFilteredEmojisAtom, mockFilteredEmojis); 60 | 61 | return render( 62 | 63 | 64 | {component} 65 | 66 | 67 | ); 68 | }; 69 | 70 | describe('EmojiSearchResults', () => { 71 | test('renders search results header', () => { 72 | const { container } = renderWithProviders(); 73 | const headers = container.querySelectorAll('[data-type="header"]'); 74 | expect(headers.length).toBe(1); 75 | }); 76 | 77 | test('renders filtered emojis in grid layout', () => { 78 | const { container } = renderWithProviders(); 79 | const emojiRows = container.querySelectorAll('[data-type="emojis"]'); 80 | expect(emojiRows.length).toBeGreaterThan(0); 81 | }); 82 | 83 | test('respects emojisPerRow from context', () => { 84 | const { container } = renderWithProviders(); 85 | const emojiGrid = container.querySelector('[data-type="emojis"]'); 86 | expect(emojiGrid).toBeDefined(); 87 | expect(emojiGrid?.querySelector('.grid')?.getAttribute('style')).toContain( 88 | 'grid-template-columns: repeat(8, minmax(0, 1fr))' 89 | ); 90 | }); 91 | 92 | test('respects containerHeight prop', () => { 93 | const { container } = renderWithProviders(); 94 | const scrollContainer = container.querySelector('.overflow-y-auto'); 95 | expect(scrollContainer).toBeDefined(); 96 | expect(scrollContainer?.getAttribute('style')).toContain('height: 300px'); 97 | }); 98 | 99 | test('handles hideStickyHeader prop', () => { 100 | const { container } = renderWithProviders(); 101 | const header = container.querySelector('[data-type="header"]'); 102 | expect(header?.getAttribute('style')).toContain('position: absolute'); 103 | }); 104 | 105 | test('renders with keyboard navigation support', () => { 106 | const { container } = renderWithProviders(); 107 | const scrollContainer = container.querySelector('.overflow-y-auto'); 108 | expect(scrollContainer?.getAttribute('tabIndex')).toBe('0'); 109 | }); 110 | 111 | test('renders virtualized list correctly', () => { 112 | const { container } = renderWithProviders(); 113 | const virtualContainer = container.querySelector('[style*="position: relative"]'); 114 | expect(virtualContainer).toBeDefined(); 115 | 116 | const virtualItems = container.querySelectorAll('[style*="position: absolute"]'); 117 | expect(virtualItems.length).toBeGreaterThan(0); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/__tests__/testData.ts: -------------------------------------------------------------------------------- 1 | import type { EmojiMetadata } from '../../types/emoji'; 2 | 3 | export const mockEmojiData = { 4 | 'Smileys & Emotion': { 5 | name: 'Smileys & Emotion', 6 | slug: 'smileys-emotion', 7 | emojis: [ 8 | { 9 | emoji: '😀', 10 | name: 'grinning face', 11 | slug: 'grinning-face', 12 | skin_tone_support: false, 13 | }, 14 | { 15 | emoji: '😃', 16 | name: 'grinning face with big eyes', 17 | slug: 'grinning-face-with-big-eyes', 18 | skin_tone_support: false, 19 | }, 20 | ], 21 | }, 22 | 'People & Body': { 23 | name: 'People & Body', 24 | slug: 'people-body', 25 | emojis: [ 26 | { 27 | emoji: '✋', 28 | name: 'raised hand', 29 | slug: 'raised-hand', 30 | skin_tone_support: true, 31 | }, 32 | { 33 | emoji: '👋', 34 | name: 'waving hand', 35 | slug: 'waving-hand', 36 | skin_tone_support: true, 37 | }, 38 | ], 39 | }, 40 | }; 41 | 42 | export const mockFilteredEmojis = [ 43 | { 44 | category: 'Smileys & Emotion', 45 | emojis: mockEmojiData['Smileys & Emotion'].emojis, 46 | }, 47 | { 48 | category: 'People & Body', 49 | emojis: mockEmojiData['People & Body'].emojis, 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/icons/ClearIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ClearIconProps extends React.SVGProps { 4 | className?: string; 5 | } 6 | 7 | export function ClearIcon({ className, ...props }: ClearIconProps) { 8 | return ( 9 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface SearchIconProps extends React.SVGProps { 4 | className?: string; 5 | } 6 | 7 | export function SearchIcon({ className, ...props }: SearchIconProps) { 8 | return ( 9 | 16 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SearchIcon'; 2 | export * from './ClearIcon'; 3 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/EmojiPicker/index.ts: -------------------------------------------------------------------------------- 1 | export { EmojiPicker } from './EmojiPicker'; 2 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/__tests__/utils/applySkinTone.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test'; 2 | import { applySkinTone } from '../../utils/applySkinTone'; 3 | 4 | import type { EmojiMetadata } from '../../utils/applySkinTone'; 5 | 6 | describe('applySkinTone()', () => { 7 | test('applies skin tone to basic emoji', () => { 8 | const emoji: EmojiMetadata = { 9 | emoji: '👋', 10 | name: 'waving hand', 11 | slug: 'waving_hand', 12 | skin_tone_support: true, 13 | }; 14 | 15 | const result = applySkinTone(emoji, 'medium'); 16 | expect(result.emoji).toBe('👋🏽'); 17 | expect(result.name).toBe('waving hand'); 18 | expect(result.slug).toBe('waving_hand'); 19 | }); 20 | 21 | test('does not apply skin tone when skin tone is not supported', () => { 22 | const emoji: EmojiMetadata = { 23 | emoji: '❤️', 24 | name: 'red heart', 25 | slug: 'red_heart', 26 | skin_tone_support: false, 27 | }; 28 | 29 | const result = applySkinTone(emoji, 'medium'); 30 | expect(result).toEqual(emoji); 31 | }); 32 | 33 | test('applies skin tone to emoji with variation selector', () => { 34 | const emoji: EmojiMetadata = { 35 | emoji: '💁‍♀️', 36 | name: 'woman tipping hand', 37 | slug: 'woman_tipping_hand', 38 | skin_tone_support: true, 39 | }; 40 | 41 | const result = applySkinTone(emoji, 'medium'); 42 | expect(result.emoji).toBe('💁🏽‍♀️'); 43 | }); 44 | 45 | test('applies skin tone to complex ZWJ sequence', () => { 46 | const emoji: EmojiMetadata = { 47 | emoji: '🧑‍🤝‍🧑', 48 | name: 'people holding hands', 49 | slug: 'people_holding_hands', 50 | skin_tone_support: true, 51 | }; 52 | 53 | const result = applySkinTone(emoji, 'dark'); 54 | expect(result.emoji).toBe('🧑🏿‍🤝‍🧑🏿'); 55 | expect(result.name).toBe('people holding hands'); 56 | expect(result.slug).toBe('people_holding_hands'); 57 | }); 58 | 59 | test('applies skin tone to emoji with multiple variation selectors', () => { 60 | const emoji: EmojiMetadata = { 61 | emoji: '🖐️', 62 | name: 'hand with fingers splayed', 63 | slug: 'hand_with_fingers_splayed', 64 | skin_tone_support: true, 65 | }; 66 | 67 | const result = applySkinTone(emoji, 'medium'); 68 | expect(result.emoji).toBe('🖐🏽'); 69 | }); 70 | 71 | test('returns original emoji when skin tone is default', () => { 72 | const emoji: EmojiMetadata = { 73 | emoji: '👋', 74 | name: 'waving hand', 75 | slug: 'waving_hand', 76 | skin_tone_support: true, 77 | }; 78 | 79 | const result = applySkinTone(emoji, 'default'); 80 | expect(result).toEqual(emoji); 81 | }); 82 | 83 | test('applies skin tone to person running right', () => { 84 | const emoji: EmojiMetadata = { 85 | emoji: '🏃', 86 | name: 'person running', 87 | slug: 'person_running', 88 | skin_tone_support: true, 89 | }; 90 | 91 | const result = applySkinTone(emoji, 'medium'); 92 | expect(result.emoji).toBe('🏃🏽'); 93 | expect(result.name).toBe('person running'); 94 | expect(result.slug).toBe('person_running'); 95 | }); 96 | 97 | test('applies skin tone to person running left', () => { 98 | const emoji: EmojiMetadata = { 99 | emoji: '🏃‍♂️', 100 | name: 'man running', 101 | slug: 'man_running', 102 | skin_tone_support: true, 103 | }; 104 | 105 | const result = applySkinTone(emoji, 'medium'); 106 | expect(result.emoji).toBe('🏃🏽‍♂️'); 107 | expect(result.name).toBe('man running'); 108 | expect(result.slug).toBe('man_running'); 109 | }); 110 | 111 | test('applies skin tone to person in manual wheelchair right', () => { 112 | const emoji: EmojiMetadata = { 113 | emoji: '🧑‍🦽', 114 | name: 'person in manual wheelchair', 115 | slug: 'person_in_manual_wheelchair', 116 | skin_tone_support: true, 117 | }; 118 | 119 | const result = applySkinTone(emoji, 'medium'); 120 | expect(result.emoji).toBe('🧑🏽‍🦽'); 121 | expect(result.name).toBe('person in manual wheelchair'); 122 | expect(result.slug).toBe('person_in_manual_wheelchair'); 123 | }); 124 | 125 | test('applies skin tone to person in manual wheelchair left', () => { 126 | const emoji: EmojiMetadata = { 127 | emoji: '👨‍🦽', 128 | name: 'man in manual wheelchair', 129 | slug: 'man_in_manual_wheelchair', 130 | skin_tone_support: true, 131 | }; 132 | 133 | const result = applySkinTone(emoji, 'dark'); 134 | expect(result.emoji).toBe('👨🏿‍🦽'); 135 | expect(result.name).toBe('man in manual wheelchair'); 136 | expect(result.slug).toBe('man_in_manual_wheelchair'); 137 | }); 138 | 139 | test('applies skin tone only to supported positions', () => { 140 | const emoji: EmojiMetadata = { 141 | emoji: '👨‍🦽', 142 | name: 'man in manual wheelchair', 143 | slug: 'man_in_manual_wheelchair', 144 | skin_tone_support: true, 145 | skin_tone_support_positions: [0], // Only apply to the person, not the wheelchair 146 | }; 147 | 148 | const result = applySkinTone(emoji, 'medium-dark'); 149 | expect(result.emoji).toBe('👨🏾‍🦽'); 150 | expect(result.name).toBe('man in manual wheelchair'); 151 | expect(result.slug).toBe('man_in_manual_wheelchair'); 152 | }); 153 | 154 | test('applies skin tone to person in motorized wheelchair right', () => { 155 | const emoji: EmojiMetadata = { 156 | emoji: '👨‍🦼', 157 | name: 'man in motorised wheelchair', 158 | slug: 'man_in_motorised_wheelchair', 159 | skin_tone_support: true, 160 | skin_tone_support_positions: [0], // Only apply to the person, not the wheelchair 161 | }; 162 | 163 | const result = applySkinTone(emoji, 'medium-dark'); 164 | expect(result.emoji).toBe('👨🏾‍🦼'); 165 | expect(result.name).toBe('man in motorised wheelchair'); 166 | expect(result.slug).toBe('man_in_motorised_wheelchair'); 167 | }); 168 | 169 | test('applies skin tone to person in motorized wheelchair with direction', () => { 170 | const emoji: EmojiMetadata = { 171 | emoji: '🧑‍🦼‍➡️', 172 | name: 'person in motorised wheelchair facing right', 173 | slug: 'person_in_motorised_wheelchair_facing_right', 174 | skin_tone_support: true, 175 | }; 176 | 177 | const result = applySkinTone(emoji, 'medium-dark'); 178 | expect(result.emoji).toBe('🧑🏾‍🦼‍➡️'); 179 | expect(result.name).toBe('person in motorised wheelchair facing right'); 180 | expect(result.slug).toBe('person_in_motorised_wheelchair_facing_right'); 181 | }); 182 | 183 | test('applies skin tone to woman detective emoji', () => { 184 | const emoji: EmojiMetadata = { 185 | emoji: '🕵️‍♀️', 186 | name: 'woman detective', 187 | slug: 'woman_detective', 188 | skin_tone_support: true, 189 | }; 190 | 191 | const result = applySkinTone(emoji, 'dark'); 192 | expect(result.emoji).toBe('🕵🏿‍♀️'); 193 | expect(result.name).toBe('woman detective'); 194 | expect(result.slug).toBe('woman_detective'); 195 | }); 196 | 197 | test('does not apply skin tone to family emoji', () => { 198 | const emoji: EmojiMetadata = { 199 | emoji: '👨‍👩‍👦', 200 | name: 'family man, woman, boy', 201 | slug: 'family_man_woman_boy', 202 | skin_tone_support: false, 203 | }; 204 | 205 | const result = applySkinTone(emoji, 'medium'); 206 | expect(result.emoji).toBe('👨‍👩‍👦'); 207 | expect(result.name).toBe('family man, woman, boy'); 208 | expect(result.slug).toBe('family_man_woman_boy'); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/__tests__/utils/emojiFilters.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test'; 2 | import { isCompatibleEmoji } from '../../utils/emojiFilters'; 3 | 4 | describe('isCompatibleEmoji', () => { 5 | test('should filter out emojis with skin tone support after version 15.0', () => { 6 | const emojiWithNewSkinToneSupport = { 7 | emoji: '🫂', 8 | name: 'people hugging', 9 | unicode_version: '13.0', 10 | emoji_version: '13.0', 11 | skin_tone_support: true, 12 | skin_tone_support_unicode_version: '15.1', 13 | }; 14 | 15 | const result = isCompatibleEmoji(emojiWithNewSkinToneSupport, 15.0); 16 | expect(result.isCompatible).toBe(true); 17 | expect(result.supportsSkinTone).toBe(false); 18 | }); 19 | 20 | test('should allow emojis with skin tone support before version 15.0', () => { 21 | const emojiWithOldSkinToneSupport = { 22 | emoji: '👋', 23 | name: 'waving hand', 24 | unicode_version: '6.0', 25 | emoji_version: '6.0', 26 | skin_tone_support: true, 27 | skin_tone_support_unicode_version: '8.0', 28 | }; 29 | 30 | const result = isCompatibleEmoji(emojiWithOldSkinToneSupport, 15.0); 31 | expect(result.isCompatible).toBe(true); 32 | expect(result.supportsSkinTone).toBe(true); 33 | }); 34 | 35 | test('should handle emojis without skin tone support', () => { 36 | const emojiWithoutSkinToneSupport = { 37 | emoji: '🌟', 38 | name: 'glowing star', 39 | unicode_version: '6.0', 40 | emoji_version: '6.0', 41 | skin_tone_support: false, 42 | }; 43 | 44 | const result = isCompatibleEmoji(emojiWithoutSkinToneSupport, 15.0); 45 | expect(result.isCompatible).toBe(true); 46 | expect(result.supportsSkinTone).toBe(false); 47 | }); 48 | 49 | test('should handle invalid unicode versions', () => { 50 | const emojiWithInvalidVersion = { 51 | emoji: '🤔', 52 | name: 'thinking face', 53 | unicode_version: 'invalid', 54 | emoji_version: '6.0', 55 | skin_tone_support: true, 56 | skin_tone_support_unicode_version: '8.0', 57 | }; 58 | 59 | const result = isCompatibleEmoji(emojiWithInvalidVersion, 15.0); 60 | expect(result.isCompatible).toBe(false); 61 | expect(result.supportsSkinTone).toBe(false); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/__tests__/utils/emojiSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test'; 2 | import { processEmojiData, searchEmojis } from '../../utils/emojiSearch'; 3 | 4 | describe('Emoji Search', () => { 5 | const mockEmojiData = { 6 | 'Smileys & Emotion': { 7 | name: 'Smileys & Emotion', 8 | slug: 'smileys_emotion', 9 | emojis: { 10 | grinning: { 11 | emoji: '😀', 12 | name: 'grinning face', 13 | slug: 'grinning_face', 14 | unicode_version: '6.1', 15 | emoji_version: '6.0', 16 | skin_tone_support: false, 17 | }, 18 | grin: { 19 | emoji: '😁', 20 | name: 'beaming face with smiling eyes', 21 | slug: 'beaming_face_with_smiling_eyes', 22 | unicode_version: '6.0', 23 | emoji_version: '6.0', 24 | skin_tone_support: false, 25 | }, 26 | new_emoji: { 27 | emoji: '🫨', 28 | name: 'shaking face', 29 | slug: 'shaking_face', 30 | unicode_version: '15.1', 31 | emoji_version: '15.1', 32 | skin_tone_support: false, 33 | }, 34 | }, 35 | }, 36 | }; 37 | 38 | test('processEmojiData processes emoji data correctly', () => { 39 | const processed = processEmojiData(mockEmojiData); 40 | expect(processed).toHaveLength(3); 41 | expect(processed[0]).toEqual({ 42 | emoji: '😀', 43 | name: 'grinning face', 44 | group: 'Smileys & Emotion', 45 | skin_tone_support: false, 46 | skin_tone_support_unicode_version: undefined, 47 | unicode_version: '6.1', 48 | emoji_version: '6.0', 49 | }); 50 | }); 51 | 52 | test('searchEmojis finds emojis by name', () => { 53 | const processed = processEmojiData(mockEmojiData); 54 | const results = searchEmojis('grinning', processed); 55 | expect(results).toHaveLength(1); 56 | expect(results[0].emojis[0].emoji).toBe('😀'); 57 | }); 58 | 59 | test('searchEmojis finds emojis by partial match', () => { 60 | const processed = processEmojiData(mockEmojiData); 61 | const results = searchEmojis('beam', processed); 62 | expect(results).toHaveLength(1); 63 | expect(results[0].emojis[0].emoji).toBe('😁'); 64 | }); 65 | 66 | test('searchEmojis returns empty array for no matches', () => { 67 | const processed = processEmojiData(mockEmojiData); 68 | const results = searchEmojis('xyz', processed); 69 | expect(results).toHaveLength(0); 70 | }); 71 | 72 | test('searchEmojis returns empty array for empty search', () => { 73 | const processed = processEmojiData(mockEmojiData); 74 | const results = searchEmojis('', processed); 75 | expect(results).toHaveLength(0); 76 | }); 77 | 78 | test('searchEmojis does not return emojis that are not compatible with version 15.0', () => { 79 | const processed = processEmojiData(mockEmojiData); 80 | const results = searchEmojis('shaking', processed); 81 | expect(results).toHaveLength(0); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/atoms/__tests__/emoji.test.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultStore } from 'jotai'; 2 | import { beforeEach, describe, expect, test } from 'bun:test'; 3 | import { 4 | combinedEmojiStateAtom, 5 | filteredEmojisAtom, 6 | hoveredEmojiAtom, 7 | isEmojiSelectedAtom, 8 | searchAtom, 9 | selectedEmojiAtom, 10 | selectedPositionAtom, 11 | skinToneAtom, 12 | } from '../emoji'; 13 | 14 | import type { EmojiMetadata } from '../../types/emoji'; 15 | 16 | describe('Emoji Atoms', () => { 17 | const store = getDefaultStore(); 18 | 19 | // Reset store before each test 20 | beforeEach(() => { 21 | store.set(hoveredEmojiAtom, null); 22 | store.set(selectedEmojiAtom, null); 23 | store.set(selectedPositionAtom, null); 24 | store.set(searchAtom, ''); 25 | store.set(skinToneAtom, 'default'); 26 | }); 27 | 28 | test('hoveredEmojiAtom updates correctly', () => { 29 | const testEmoji = { 30 | emoji: '😀', 31 | name: 'grinning face', 32 | slug: 'grinning-face', 33 | skin_tone_support: false, 34 | }; 35 | 36 | store.set(hoveredEmojiAtom, testEmoji); 37 | expect(store.get(hoveredEmojiAtom)).toEqual(testEmoji); 38 | }); 39 | 40 | test('selectedEmojiAtom updates correctly', () => { 41 | store.set(selectedEmojiAtom, '😀'); 42 | expect(store.get(selectedEmojiAtom)).toBe('😀'); 43 | }); 44 | 45 | test('selectedPositionAtom updates correctly', () => { 46 | const position = { row: 1, column: 2 }; 47 | store.set(selectedPositionAtom, position); 48 | expect(store.get(selectedPositionAtom)).toEqual(position); 49 | }); 50 | 51 | test('searchAtom updates correctly', () => { 52 | store.set(searchAtom, 'smile'); 53 | expect(store.get(searchAtom)).toBe('smile'); 54 | }); 55 | 56 | test('skinToneAtom updates correctly', () => { 57 | store.set(skinToneAtom, 'light'); 58 | expect(store.get(skinToneAtom)).toBe('light'); 59 | }); 60 | 61 | test('isEmojiSelectedAtom returns correct state', () => { 62 | const position = { row: 1, column: 2 }; 63 | store.set(selectedPositionAtom, position); 64 | 65 | // Test matching position 66 | const selectedAtom1 = isEmojiSelectedAtom(1, 2); 67 | expect(store.get(selectedAtom1)).toBe(true); 68 | 69 | // Test non-matching position 70 | const selectedAtom2 = isEmojiSelectedAtom(1, 3); 71 | expect(store.get(selectedAtom2)).toBe(false); 72 | }); 73 | 74 | test('filteredEmojisAtom returns all emojis when search is empty', () => { 75 | store.set(searchAtom, ''); 76 | const filtered = store.get(filteredEmojisAtom); 77 | expect(filtered.length).toBeGreaterThan(0); 78 | expect(filtered[0]).toHaveProperty('category'); 79 | expect(filtered[0]).toHaveProperty('emojis'); 80 | }); 81 | 82 | test('combinedEmojiStateAtom combines multiple atom states', () => { 83 | const testEmoji = { 84 | emoji: '😀', 85 | name: 'grinning face', 86 | slug: 'grinning-face', 87 | skin_tone_support: false, 88 | }; 89 | 90 | store.set(hoveredEmojiAtom, testEmoji); 91 | store.set(selectedEmojiAtom, '😀'); 92 | store.set(skinToneAtom, 'light'); 93 | 94 | expect(store.get(combinedEmojiStateAtom)).toEqual({ 95 | hoveredEmoji: testEmoji, 96 | selectedEmoji: '😀', 97 | skinTone: 'light', 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/atoms/emoji.ts: -------------------------------------------------------------------------------- 1 | import emojiData from 'unicode-emoji-json/data-by-group.json'; 2 | import { atom } from 'jotai'; 3 | import { processEmojiData, searchEmojis } from '../utils/emojiSearch'; 4 | import { isCompatibleEmoji } from '../utils/emojiFilters'; 5 | 6 | import type { EmojiMetadata, SkinTone } from '../types/emoji'; 7 | 8 | export const hoveredEmojiAtom = atom(null); 9 | export const selectedEmojiAtom = atom(null); 10 | export const selectedPositionAtom = atom<{ row: number; column: number } | null>(null); 11 | export const searchAtom = atom(''); 12 | export const skinToneAtom = atom('default'); 13 | 14 | // Create separate atoms for different concerns to avoid unnecessary re-renders 15 | export const skinToneOnlyAtom = atom((get) => get(skinToneAtom)); 16 | 17 | export const isEmojiSelectedAtom = (rowIndex: number, columnIndex: number) => 18 | atom((get) => { 19 | const selectedPos = get(selectedPositionAtom); 20 | return selectedPos?.row === rowIndex && selectedPos?.column === columnIndex; 21 | }); 22 | 23 | // Process emoji data once and filter compatible emojis 24 | const processedEmojiData = processEmojiData(emojiData); 25 | const defaultEmojis = Object.entries(emojiData).map(([category, group]) => ({ 26 | category, 27 | emojis: (group as any).emojis 28 | .filter((emoji: any) => { 29 | const { isCompatible } = isCompatibleEmoji(emoji, 15.0); 30 | return isCompatible; 31 | }) 32 | .map((emoji: any) => ({ 33 | emoji: emoji.emoji, 34 | name: emoji.name, 35 | slug: emoji.slug, 36 | skin_tone_support: emoji.skin_tone_support, 37 | skin_tone_support_unicode_version: emoji.skin_tone_support_unicode_version, 38 | })), 39 | })); 40 | 41 | // Derived atom for filtered emojis with memoization 42 | export const filteredEmojisAtom = atom((get) => { 43 | const search = get(searchAtom); 44 | 45 | if (!search.trim()) { 46 | return defaultEmojis; 47 | } 48 | 49 | return searchEmojis(search, processedEmojiData).map((group) => ({ 50 | category: group.category, 51 | emojis: group.emojis, 52 | })); 53 | }); 54 | 55 | export const combinedEmojiStateAtom = atom((get) => ({ 56 | hoveredEmoji: get(hoveredEmojiAtom), 57 | selectedEmoji: get(selectedEmojiAtom), 58 | skinTone: get(skinToneAtom), 59 | })); 60 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/hooks/useEmojiKeyboardNavigation.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | import { useAtomValue, useSetAtom } from 'jotai'; 3 | import { useEmojiPicker } from '../EmojiPicker/EmojiPickerContext'; 4 | import { 5 | hoveredEmojiAtom, 6 | searchAtom, 7 | selectedEmojiAtom, 8 | selectedPositionAtom, 9 | } from '../atoms/emoji'; 10 | 11 | import type { EmojiMetadata } from '../types/emoji'; 12 | type Row = { type: 'header'; content: string } | { type: 'emojis'; content: EmojiMetadata[] }; 13 | 14 | interface UseEmojiKeyboardNavigationProps { 15 | rows: Row[]; 16 | virtualizer: { 17 | scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => void; 18 | }; 19 | } 20 | 21 | export function useEmojiKeyboardNavigation({ rows, virtualizer }: UseEmojiKeyboardNavigationProps) { 22 | const setSelectedPosition = useSetAtom(selectedPositionAtom); 23 | const setHoveredEmoji = useSetAtom(hoveredEmojiAtom); 24 | const setSelectedEmoji = useSetAtom(selectedEmojiAtom); 25 | const search = useAtomValue(searchAtom); 26 | const selectedPosition = useAtomValue(selectedPositionAtom); 27 | const { onEmojiSelect } = useEmojiPicker(); 28 | 29 | const selectedRow = selectedPosition?.row ?? -1; 30 | const selectedColumn = selectedPosition?.column ?? -1; 31 | 32 | const findNextEmojiRow = useCallback( 33 | (currentRow: number, direction: 'up' | 'down'): number => { 34 | let nextRow = currentRow; 35 | 36 | while (true) { 37 | nextRow = direction === 'up' ? nextRow - 1 : nextRow + 1; 38 | 39 | // Check bounds 40 | if (nextRow < 0 || nextRow >= rows.length) { 41 | return currentRow; 42 | } 43 | 44 | // Found next emoji row 45 | if (rows[nextRow].type === 'emojis') { 46 | return nextRow; 47 | } 48 | } 49 | }, 50 | [rows] 51 | ); 52 | 53 | // Find first emoji row 54 | const findFirstEmojiRow = useCallback((): number => { 55 | for (let i = 0; i < rows.length; i++) { 56 | if (rows[i].type === 'emojis') { 57 | return i; 58 | } 59 | } 60 | return -1; 61 | }, [rows]); 62 | 63 | // Focus first emoji when search query changes and there are results 64 | useEffect(() => { 65 | if (search.trim() && rows.length > 0) { 66 | const firstRow = findFirstEmojiRow(); 67 | if (firstRow !== -1) { 68 | setSelectedPosition({ row: firstRow, column: 0 }); 69 | const firstRowData = rows[firstRow]; 70 | if (firstRowData?.type === 'emojis' && firstRowData.content[0]) { 71 | setHoveredEmoji(firstRowData.content[0]); 72 | virtualizer.scrollToIndex(firstRow, { align: 'center' }); 73 | } 74 | } 75 | } 76 | }, [search, rows, findFirstEmojiRow, setSelectedPosition, setHoveredEmoji, virtualizer]); 77 | 78 | // Handle keyboard navigation 79 | useEffect(() => { 80 | const handleKeyDown = (e: KeyboardEvent) => { 81 | if (!selectedPosition) { 82 | // If nothing is selected and arrow keys are pressed, select the first emoji 83 | if (['ArrowDown', 'ArrowRight'].includes(e.key)) { 84 | e.preventDefault(); 85 | const firstRow = findFirstEmojiRow(); 86 | if (firstRow !== -1) { 87 | const firstRowData = rows[firstRow]; 88 | if (firstRowData?.type === 'emojis' && firstRowData.content[0]) { 89 | setSelectedPosition({ row: firstRow, column: 0 }); 90 | setHoveredEmoji(firstRowData.content[0]); 91 | virtualizer.scrollToIndex(firstRow, { align: 'center' }); 92 | } 93 | } 94 | } 95 | return; 96 | } 97 | 98 | const currentRow = rows[selectedRow]; 99 | if (!currentRow || currentRow.type !== 'emojis') return; 100 | 101 | const maxColumns = currentRow.content.length; 102 | 103 | switch (e.key) { 104 | case 'ArrowUp': { 105 | e.preventDefault(); 106 | const nextRow = findNextEmojiRow(selectedRow, 'up'); 107 | if (nextRow !== selectedRow) { 108 | const nextRowData = rows[nextRow]; 109 | if (nextRowData?.type === 'emojis') { 110 | const nextColumn = Math.min(selectedColumn, nextRowData.content.length - 1); 111 | setSelectedPosition({ row: nextRow, column: nextColumn }); 112 | setHoveredEmoji(nextRowData.content[nextColumn]); 113 | virtualizer.scrollToIndex(nextRow, { align: 'center' }); 114 | } 115 | } 116 | break; 117 | } 118 | case 'ArrowDown': { 119 | e.preventDefault(); 120 | const nextRow = findNextEmojiRow(selectedRow, 'down'); 121 | if (nextRow !== selectedRow) { 122 | const nextRowData = rows[nextRow]; 123 | if (nextRowData?.type === 'emojis') { 124 | const nextColumn = Math.min(selectedColumn, nextRowData.content.length - 1); 125 | setSelectedPosition({ row: nextRow, column: nextColumn }); 126 | setHoveredEmoji(nextRowData.content[nextColumn]); 127 | virtualizer.scrollToIndex(nextRow, { align: 'center' }); 128 | } 129 | } 130 | break; 131 | } 132 | case 'ArrowLeft': { 133 | e.preventDefault(); 134 | if (selectedColumn > 0) { 135 | const nextColumn = selectedColumn - 1; 136 | setSelectedPosition({ row: selectedRow, column: nextColumn }); 137 | setHoveredEmoji(currentRow.content[nextColumn]); 138 | } 139 | break; 140 | } 141 | case 'ArrowRight': { 142 | e.preventDefault(); 143 | if (selectedColumn < maxColumns - 1) { 144 | const nextColumn = selectedColumn + 1; 145 | setSelectedPosition({ row: selectedRow, column: nextColumn }); 146 | setHoveredEmoji(currentRow.content[nextColumn]); 147 | } 148 | break; 149 | } 150 | case 'Enter': 151 | case ' ': { 152 | e.preventDefault(); 153 | const emoji = currentRow.content[selectedColumn]; 154 | if (emoji) { 155 | setSelectedEmoji(emoji.emoji); 156 | if (onEmojiSelect) { 157 | onEmojiSelect(emoji.emoji); 158 | } 159 | } 160 | break; 161 | } 162 | } 163 | }; 164 | 165 | document.addEventListener('keydown', handleKeyDown); 166 | return () => document.removeEventListener('keydown', handleKeyDown); 167 | }, [ 168 | rows, 169 | selectedRow, 170 | selectedColumn, 171 | selectedPosition, 172 | findNextEmojiRow, 173 | setSelectedPosition, 174 | setHoveredEmoji, 175 | setSelectedEmoji, 176 | virtualizer, 177 | onEmojiSelect, 178 | ]); 179 | } 180 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/hooks/useVirtualizedList.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from 'react'; 2 | import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'; 3 | 4 | interface UseVirtualizedListOptions { 5 | rows: T[]; 6 | getScrollElement: () => HTMLElement | null; 7 | estimateSize: (index: number) => number; 8 | isHeader: (row: T) => boolean; 9 | hideStickyHeader?: boolean; 10 | } 11 | 12 | export function useVirtualizedList({ 13 | rows, 14 | getScrollElement, 15 | estimateSize, 16 | isHeader, 17 | hideStickyHeader = false, 18 | }: UseVirtualizedListOptions) { 19 | const activeStickyIndexRef = useRef(0); 20 | 21 | const stickyIndexes = useMemo(() => { 22 | if (hideStickyHeader) return []; 23 | return rows.reduce((acc, row, index) => { 24 | if (isHeader(row)) { 25 | acc.push(index); 26 | } 27 | return acc; 28 | }, []); 29 | }, [rows, isHeader, hideStickyHeader]); 30 | 31 | const isSticky = (index: number) => stickyIndexes.includes(index); 32 | const isActiveSticky = (index: number) => 33 | !hideStickyHeader && activeStickyIndexRef.current === index; 34 | 35 | const virtualizer = useVirtualizer({ 36 | count: rows.length, 37 | getScrollElement, 38 | estimateSize, 39 | overscan: 5, 40 | paddingEnd: 8, 41 | rangeExtractor: (range) => { 42 | if (hideStickyHeader) { 43 | return defaultRangeExtractor(range); 44 | } 45 | 46 | const activeIndex = stickyIndexes.findLast((index) => range.startIndex >= index) ?? 0; 47 | activeStickyIndexRef.current = activeIndex; 48 | 49 | const defaultRange = defaultRangeExtractor(range); 50 | if (defaultRange.includes(activeIndex)) { 51 | return defaultRange; 52 | } 53 | return [activeIndex, ...defaultRange]; 54 | }, 55 | }); 56 | 57 | return { 58 | virtualizer, 59 | isSticky, 60 | isActiveSticky, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/index.ts: -------------------------------------------------------------------------------- 1 | export { EmojiPicker } from './EmojiPicker/EmojiPicker'; 2 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/test/happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from '@happy-dom/global-registrator'; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/test/matchers.d.ts: -------------------------------------------------------------------------------- 1 | import { AsymmetricMatchers, Matchers } from 'bun:test'; 2 | import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; 3 | 4 | declare module 'bun:test' { 5 | interface Matchers extends TestingLibraryMatchers {} 6 | interface AsymmetricMatchers extends TestingLibraryMatchers {} 7 | } 8 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, expect } from 'bun:test'; 2 | import { cleanup } from '@testing-library/react'; 3 | import * as matchers from '@testing-library/jest-dom/matchers'; 4 | 5 | expect.extend(matchers); 6 | 7 | // Automatically cleanup after each test 8 | afterEach(() => { 9 | cleanup(); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/types/emoji.ts: -------------------------------------------------------------------------------- 1 | export type SkinTone = 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' | 'default'; 2 | 3 | export interface EmojiMetadata { 4 | emoji: string; 5 | name: string; 6 | slug: string; 7 | skin_tone_support: boolean; 8 | skin_tone_support_positions?: number[]; 9 | skin_tone_support_unicode_version?: string; 10 | } 11 | 12 | export interface EmojiDataItem { 13 | emoji: string; 14 | name: string; 15 | unicode_version: string; 16 | emoji_version: string; 17 | skin_tone_support: boolean; 18 | skin_tone_support_unicode_version?: string; 19 | slug: string; 20 | } 21 | 22 | export interface SkinToneData { 23 | emoji: string; 24 | skin_tone_support: boolean; 25 | skin_tone_support_unicode_version?: string; 26 | unicode_version?: string; 27 | } 28 | 29 | export interface EmojiGroupData { 30 | name: string; 31 | slug: string; 32 | emojis: EmojiDataItem[]; 33 | } 34 | 35 | export type EmojiData = { 36 | emoji: string; 37 | name: string; 38 | group: string; 39 | skin_tone_support: boolean; 40 | skin_tone_support_unicode_version?: string; 41 | unicode_version: string; 42 | emoji_version: string; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/utils/applySkinTone.ts: -------------------------------------------------------------------------------- 1 | import type { SkinTone } from '@/types/emoji'; 2 | 3 | export interface EmojiMetadata { 4 | emoji: string; 5 | name: string; 6 | slug: string; 7 | skin_tone_support: boolean; 8 | skin_tone_support_positions?: number[]; 9 | } 10 | 11 | // Unicode Emoji 15.0 12 | // https://unicode.org/Public/emoji/15.0/emoji-sequences.txt 13 | 14 | export function applySkinTone(emoji: EmojiMetadata, skinTone: SkinTone): EmojiMetadata { 15 | if (!skinTone || !emoji.skin_tone_support) { 16 | return emoji; 17 | } 18 | const skinTonMap = { 19 | default: '', 20 | light: '\u{1F3FB}', 21 | 'medium-light': '\u{1F3FC}', 22 | medium: '\u{1F3FD}', 23 | 'medium-dark': '\u{1F3FE}', 24 | dark: '\u{1F3FF}', 25 | }; 26 | 27 | let zwj = '\u200D'; 28 | 29 | // Hand Shake 🧑‍🤝‍🧑 30 | if (emoji.emoji.includes('\u200d\ud83e\udd1d\u200d')) { 31 | zwj = '\u200d\ud83e\udd1d\u200d'; 32 | } 33 | 34 | const parts = emoji.emoji.split(zwj); 35 | const modifiedParts = parts.map((part) => { 36 | const basePart = part.replace(/\p{Emoji_Modifier}/gu, ''); 37 | 38 | if (/\p{Emoji_Modifier_Base}/u.test(basePart)) { 39 | return basePart.replace( 40 | /(\p{Extended_Pictographic}+)(\uFE0F?)/u, 41 | `$1${skinTonMap[skinTone]}` 42 | ); 43 | } 44 | return part; 45 | }); 46 | 47 | const newEmoji = modifiedParts.join(zwj); 48 | 49 | return { 50 | ...emoji, 51 | emoji: newEmoji, 52 | name: `${emoji.name}`, 53 | slug: `${emoji.slug}`, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { twMerge } from 'tailwind-merge'; 2 | import { clsx } from 'clsx'; 3 | 4 | import type { ClassValue } from 'clsx'; 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/utils/emojiFilters.ts: -------------------------------------------------------------------------------- 1 | interface EmojiDataItem { 2 | emoji: string; 3 | name: string; 4 | unicode_version: string; 5 | emoji_version: string; 6 | skin_tone_support: boolean; 7 | skin_tone_support_unicode_version?: string; 8 | slug?: string; 9 | } 10 | 11 | interface SkinToneData { 12 | emoji: string; 13 | skin_tone_support: boolean; 14 | skin_tone_support_unicode_version?: string; 15 | unicode_version?: string; 16 | } 17 | 18 | export const isCompatibleSkinTone = (emoji: SkinToneData, maxVersion: number = 15.0): boolean => { 19 | // First check if the emoji itself is from a compatible version 20 | if (emoji.unicode_version) { 21 | const version = parseFloat(emoji.unicode_version); 22 | if (isNaN(version) || version > maxVersion) { 23 | return false; 24 | } 25 | } 26 | 27 | if (!emoji.skin_tone_support) { 28 | return false; 29 | } 30 | 31 | if (emoji.skin_tone_support_unicode_version) { 32 | const skinToneVersion = parseFloat(emoji.skin_tone_support_unicode_version); 33 | return !isNaN(skinToneVersion) && skinToneVersion <= maxVersion; 34 | } 35 | 36 | return true; 37 | }; 38 | 39 | export const isCompatibleEmoji = ( 40 | emoji: EmojiDataItem, 41 | maxVersion: number = 15.0 42 | ): { isCompatible: boolean; supportsSkinTone: boolean } => { 43 | const version = parseFloat(emoji.unicode_version); 44 | if (isNaN(version) || version > maxVersion) { 45 | return { isCompatible: false, supportsSkinTone: false }; 46 | } 47 | 48 | return { 49 | isCompatible: true, 50 | supportsSkinTone: isCompatibleSkinTone({ ...emoji }, maxVersion), 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/utils/emojiSearch.ts: -------------------------------------------------------------------------------- 1 | import { filterSupportedEmojis } from './supportedEmojis'; 2 | import { isCompatibleEmoji } from './emojiFilters'; 3 | 4 | import type { 5 | EmojiData, 6 | EmojiGroupData as EmojiGroup, 7 | EmojiMetadata, 8 | EmojiDataItem, 9 | } from '../types/emoji'; 10 | 11 | type GroupedEmojis = { 12 | category: string; 13 | emojis: EmojiMetadata[]; 14 | }; 15 | 16 | export const processEmojiData = (emojiData: any): EmojiData[] => { 17 | const processed: EmojiData[] = []; 18 | 19 | // Process each category 20 | for (const group of Object.values(emojiData)) { 21 | if (!group || typeof group !== 'object') continue; 22 | 23 | const { name: groupName, emojis } = group as any; 24 | if (!groupName || !emojis || typeof emojis !== 'object') continue; 25 | 26 | // Process emojis in this group 27 | for (const emoji of Object.values(emojis)) { 28 | if (!emoji || typeof emoji !== 'object') continue; 29 | 30 | const { 31 | emoji: char, 32 | name, 33 | skin_tone_support, 34 | skin_tone_support_unicode_version, 35 | unicode_version, 36 | emoji_version, 37 | } = emoji as any; 38 | if (char && name) { 39 | processed.push({ 40 | emoji: char, 41 | name: name.toLowerCase(), 42 | group: groupName, 43 | skin_tone_support: !!skin_tone_support, 44 | skin_tone_support_unicode_version, 45 | unicode_version, 46 | emoji_version, 47 | }); 48 | } 49 | } 50 | } 51 | 52 | return processed; 53 | }; 54 | 55 | export const groupEmojisByCategory = (emojiData: any): GroupedEmojis[] => { 56 | if (!emojiData || typeof emojiData !== 'object') { 57 | return []; 58 | } 59 | 60 | const groups: EmojiGroup[] = []; 61 | 62 | for (const group of Object.values(emojiData)) { 63 | if (!group || typeof group !== 'object') continue; 64 | 65 | const { name, slug, emojis } = group as any; 66 | if (!name || !emojis || typeof emojis !== 'object') continue; 67 | 68 | const emojiItems: EmojiDataItem[] = []; 69 | for (const emoji of Object.values(emojis)) { 70 | if (!emoji || typeof emoji !== 'object') continue; 71 | 72 | const { 73 | emoji: char, 74 | name: emojiName, 75 | slug: emojiSlug, 76 | skin_tone_support, 77 | skin_tone_support_unicode_version, 78 | unicode_version, 79 | emoji_version, 80 | } = emoji as any; 81 | 82 | if (char && emojiName) { 83 | emojiItems.push({ 84 | emoji: char, 85 | name: emojiName, 86 | slug: emojiSlug || emojiName.toLowerCase().replace(/\s+/g, '_'), 87 | skin_tone_support: !!skin_tone_support, 88 | skin_tone_support_unicode_version, 89 | unicode_version: unicode_version || '', 90 | emoji_version: emoji_version || '', 91 | }); 92 | } 93 | } 94 | 95 | if (emojiItems.length > 0) { 96 | groups.push({ 97 | name, 98 | slug: slug || name.toLowerCase().replace(/\s+/g, '_'), 99 | emojis: emojiItems, 100 | }); 101 | } 102 | } 103 | 104 | return filterSupportedEmojis(groups); 105 | }; 106 | 107 | export const searchEmojis = (searchTerm: string, processedData: EmojiData[]): GroupedEmojis[] => { 108 | // Return empty array for empty search 109 | if (!searchTerm.trim()) { 110 | return []; 111 | } 112 | 113 | const normalizedSearch = searchTerm.toLowerCase(); 114 | const matchingEmojis = processedData.filter( 115 | (emoji) => 116 | emoji.name.includes(normalizedSearch) && 117 | isCompatibleEmoji({ 118 | emoji: emoji.emoji, 119 | name: emoji.name, 120 | unicode_version: emoji.unicode_version || '', 121 | emoji_version: emoji.emoji_version || '', 122 | skin_tone_support: emoji.skin_tone_support, 123 | skin_tone_support_unicode_version: emoji.skin_tone_support_unicode_version, 124 | slug: emoji.name.replace(/\s+/g, '_'), 125 | }).isCompatible 126 | ); 127 | 128 | if (matchingEmojis.length === 0) { 129 | return []; 130 | } 131 | 132 | // Group matching emojis by category 133 | const groupedResults = new Map(); 134 | 135 | matchingEmojis.forEach((emoji) => { 136 | const metadata: EmojiMetadata = { 137 | emoji: emoji.emoji, 138 | name: emoji.name, 139 | slug: emoji.name.replace(/\s+/g, '_'), 140 | skin_tone_support: emoji.skin_tone_support, 141 | skin_tone_support_unicode_version: emoji.skin_tone_support_unicode_version, 142 | }; 143 | 144 | if (!groupedResults.has(emoji.group)) { 145 | groupedResults.set(emoji.group, []); 146 | } 147 | groupedResults.get(emoji.group)!.push(metadata); 148 | }); 149 | 150 | return Array.from(groupedResults.entries()).map(([category, emojis]) => ({ 151 | category, 152 | emojis, 153 | })); 154 | }; 155 | -------------------------------------------------------------------------------- /packages/emoji-picker/src/utils/supportedEmojis.ts: -------------------------------------------------------------------------------- 1 | import { isCompatibleEmoji, isCompatibleSkinTone } from './emojiFilters'; 2 | 3 | export type EmojiGroup = { 4 | name: string; 5 | slug: string; 6 | emojis: EmojiDataItem[]; 7 | }; 8 | 9 | export interface EmojiDataItem { 10 | emoji: string; 11 | name: string; 12 | slug: string; 13 | skin_tone_support: boolean; 14 | skin_tone_support_unicode_version?: string; 15 | unicode_version: string; 16 | emoji_version: string; 17 | } 18 | 19 | export type EmojiMetadata = { 20 | emoji: string; 21 | name: string; 22 | slug: string; 23 | skin_tone_support: boolean; 24 | skin_tone_support_unicode_version?: string; 25 | }; 26 | 27 | export type GroupedEmojis = { 28 | category: string; 29 | emojis: EmojiMetadata[]; 30 | }; 31 | 32 | export type EmojiData = { 33 | emoji: string; 34 | name: string; 35 | group: string; 36 | skin_tone_support: boolean; 37 | skin_tone_support_unicode_version?: string; 38 | unicode_version?: string; 39 | emoji_version?: string; 40 | }; 41 | 42 | export function filterSupportedEmojis(emojiGroups: EmojiGroup[]): GroupedEmojis[] { 43 | if (!Array.isArray(emojiGroups)) { 44 | return []; 45 | } 46 | 47 | return emojiGroups 48 | .map((group) => ({ 49 | category: group.name, 50 | emojis: group.emojis 51 | .filter((emoji) => isCompatibleEmoji(emoji).isCompatible) 52 | .map((emoji) => ({ 53 | emoji: emoji.emoji, 54 | name: emoji.name, 55 | slug: emoji.slug, 56 | skin_tone_support: isCompatibleSkinTone(emoji), 57 | skin_tone_support_unicode_version: emoji.skin_tone_support_unicode_version, 58 | })), 59 | })) 60 | .filter((category) => category.emojis.length > 0); 61 | } 62 | -------------------------------------------------------------------------------- /packages/emoji-picker/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "dist" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": [ 11 | "node_modules", 12 | "dist", 13 | "demo", 14 | "**/*.test.ts", 15 | "**/*.test.tsx", 16 | "scripts", 17 | "src/__tests__", 18 | "**/__tests__/**", 19 | "src/test", 20 | "src/EmojiPicker/__tests__", 21 | "react-version-tests" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/emoji-picker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noFallthroughCasesInSwitch": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/emoji-picker/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts", "build.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/emoji-picker/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: resolve(__dirname, 'src/index.ts'), 8 | name: 'EmojiPicker', 9 | formats: ['es', 'cjs'], 10 | fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, 11 | }, 12 | rollupOptions: { 13 | external: ['react', 'react-dom'], 14 | output: { 15 | globals: { 16 | react: 'React', 17 | 'react-dom': 'ReactDOM', 18 | }, 19 | }, 20 | }, 21 | }, 22 | resolve: { 23 | alias: { 24 | '@': resolve(__dirname, './src'), 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /public/emoji-picker-repo-asset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ferrucc-io/emoji-picker/26abe3c3bf91e552ca65741093536cfbd984778d/public/emoji-picker-repo-asset.png -------------------------------------------------------------------------------- /scripts/release.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { execSync } from 'child_process'; 3 | 4 | const bumpVersion = (version: string, type: 'patch' | 'minor' | 'major'): string => { 5 | const [major, minor, patch] = version.replace('v', '').split('.').map(Number); 6 | switch (type) { 7 | case 'major': 8 | return `${major + 1}.0.0`; 9 | case 'minor': 10 | return `${major}.${minor + 1}.0`; 11 | case 'patch': 12 | return `${major}.${minor}.${patch + 1}`; 13 | } 14 | }; 15 | 16 | const main = async () => { 17 | const type = process.argv[2] as 'patch' | 'minor' | 'major'; 18 | if (!['patch', 'minor', 'major'].includes(type)) { 19 | console.error('Please specify version type: patch, minor, or major'); 20 | process.exit(1); 21 | } 22 | 23 | // Read current version from package.json 24 | const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); 25 | const currentVersion = pkg.version; 26 | const newVersion = bumpVersion(currentVersion, type); 27 | 28 | // Update package.json 29 | pkg.version = newVersion; 30 | writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); 31 | 32 | try { 33 | // Commit package.json changes 34 | execSync('git add package.json'); 35 | execSync(`git commit -m "chore: bump version to ${newVersion}"`); 36 | 37 | // Create and push tag 38 | execSync(`git tag v${newVersion}`); 39 | execSync('git push'); 40 | execSync('git push --tags'); 41 | 42 | console.log(`Successfully released version ${newVersion}`); 43 | } catch (error) { 44 | console.error('Error during release:', error); 45 | process.exit(1); 46 | } 47 | }; 48 | 49 | main().catch(console.error); -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react(), 8 | tailwindcss(), 9 | ], 10 | root: 'demo', 11 | build: { 12 | outDir: '../dist', 13 | }, 14 | }); --------------------------------------------------------------------------------