├── .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 | [](https://www.npmjs.com/package/@ferrucc-io/emoji-picker)
9 | [](https://www.npmjs.com/package/@ferrucc-io/emoji-picker)
10 | [](https://github.com/ferrucc-io/emojicn/blob/main/LICENSE)
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 | Add Emoji
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 | Add Emoji
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 |
103 |
104 |
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 | onTabChange(id)}
25 | className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
26 | activeTab === id
27 | ? "bg-white text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
28 | : "text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
29 | }`}
30 | >
31 | {label}
32 |
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 |
13 | {theme === "light" ? (
14 |
22 |
27 |
28 | ) : (
29 |
37 |
42 |
43 | )}
44 |
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 |
26 |
38 |
39 |
40 |
41 |
42 |
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 | setActiveTab(manager)}
31 | className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
32 | activeTab === manager
33 | ? "bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
34 | : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50"
35 | }`}
36 | >
37 | {manager}
38 |
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 | setActiveTab(version)}
42 | className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
43 | activeTab === version
44 | ? "bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
45 | : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50"
46 | }`}
47 | >
48 | Tailwind {version}
49 |
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 | setVariant(tab.id as typeof variant)}
53 | className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
54 | variant === tab.id
55 | ? "bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100"
56 | : "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50"
57 | }`}
58 | >
59 | {tab.label}
60 |
61 | ))}
62 |
63 |
64 |
65 |
66 |
67 | Theme:
68 |
69 |
70 | {THEMES.map(({ value, label }) => (
71 |
80 | {label}
81 |
82 | ))}
83 |
84 |
85 |
86 |
87 |
88 |
setActiveTab("preview")}
90 | className={`px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
91 | activeTab === "preview"
92 | ? "text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-800"
93 | : "text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
94 | }`}
95 | >
96 |
103 |
104 |
105 |
106 | Preview
107 |
108 |
setActiveTab("code")}
110 | className={`px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
111 | activeTab === "code"
112 | ? "text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-800"
113 | : "text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100"
114 | }`}
115 | >
116 |
123 |
124 |
125 | Code
126 |
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 |
33 | Add Emoji
34 |
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 | 
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 ? : Add Emoji }
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 | Test
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 | Test
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 |
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(Test );
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 |
104 | {emojiWithSkinTone.emoji}
105 |
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 |
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 |
67 | {endIcon}
68 |
69 | ) : (
70 |
71 |
72 |
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 | {
31 | setSkinTone(tone.tone);
32 | setIsPickerOpen(false);
33 | }}
34 | >
35 | {tone.emoji}
36 |
37 | ))}
38 |
39 |
40 | Choose your default skin tone
41 |
42 |
43 | );
44 | }
45 |
46 | return (
47 | setIsPickerOpen(true)}
50 | >
51 | {currentTone.emoji}
52 |
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 | });
--------------------------------------------------------------------------------