318 | { showNavbar &&
319 |
320 | }
321 |
322 | { pickerState.searchEmojis.emojis
323 | ? Object.values(pickerState.searchEmojis.emojis).flat().length
324 | ?
325 | :
328 | : showScroll &&
329 |
330 | }
331 |
332 | { showFooter &&
333 |
336 | )
337 | }
338 |
339 | export const EmojiPicker = forwardRef(EmojiPickerRefComponent);
--------------------------------------------------------------------------------
/src/EmojiPicker/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, memo } from "react";
2 | import Emoji from "../Emoji"
3 | import { EmojiObject } from "../utils"
4 |
5 | const Footer: FunctionComponent<{emoji: EmojiObject | undefined, emojiPreviewName, [key: string]: any}> = ({emoji, emojiPreviewName, ...props}) => {
6 | return (
7 | , handleSelectInNavbar: Function, [key: string]: any}> = ({data, handleSelectInNavbar, ...props}) => {
6 |
7 | // roving tabindex
8 | const [index, setIndex] = useState(0);
9 |
10 | const onNavbarKeyDown = (event: KeyboardEvent) => {
11 | switch (event.key) {
12 | case 'Enter':
13 | return handleSelectInNavbar(Object.keys(data)[index]);
14 | case 'ArrowLeft':
15 | return index > 0 && setIndex(index => index - 1);
16 | case 'ArrowRight':
17 | return index < Object.keys(data).length - 1 && setIndex(index => index + 1)
18 | case 'Home':
19 | return index > 0 && setIndex(0);
20 | case 'End':
21 | return index < Object.keys(data).length - 1 && setIndex(Object.keys(data).length - 1)
22 | }
23 | }
24 |
25 | const onNavbarClick = (index: number, category: string) => (event: MouseEvent) => {
26 | setIndex(index);
27 | handleSelectInNavbar(category);
28 | }
29 |
30 | return (
31 |
32 | { Object.entries(data).map(([category, list], i) => {
33 | const props = {
34 | className: "emoji-picker-navbar-category",
35 | key: `navbar-${category}`,
36 | onClick: onNavbarClick(i, category),
37 | onKeyDown: onNavbarKeyDown,
38 | role: "tab",
39 | "aria-label": category,
40 | "aria-selected": false,
41 | tabIndex: -1,
42 | ...i == index && {
43 | "aria-selected": true,
44 | tabIndex: 0,
45 | ref: (button: HTMLButtonElement) => Boolean(document.activeElement?.closest(".emoji-picker-navbar")) && button?.focus(),
46 | }
47 | }
48 | return (
49 |
52 | )
53 | }
54 | )}
55 |
56 | )
57 | }
58 |
59 | const MemoizedNavbar = memo(Navbar)
60 | export default MemoizedNavbar;
--------------------------------------------------------------------------------
/src/EmojiPicker/Scroll.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, useEffect, useRef, Ref, useState, memo, forwardRef, MutableRefObject, CSSProperties, MouseEvent } from "react";
2 | import { FixedSizeList as VirtualList } from 'react-window';
3 | import InfiniteLoader from "react-window-infinite-loader";
4 | import { EmojiObject, shallowDiffer, itemRange } from '../utils'
5 | import Emoji from "../Emoji";
6 |
7 | type ScrollProps = {
8 | emojisPerRow: number,
9 | emojiSize: number,
10 | numberScrollRows: number,
11 | focusedEmoji: {emoji: EmojiObject, row: number, focusOnRender: boolean, preventScroll: boolean} | null,
12 | emojiData: Record;
13 | refVirtualList: MutableRefObject,
14 | handleClickInScroll: (emoji: EmojiObject, row: number) => ((event: MouseEvent) => void) | undefined,
15 | handleMouseInScroll: (emoji: EmojiObject, row: number) => ((event: MouseEvent) => void) | undefined,
16 | itemCount: number,
17 | itemRanges: itemRange[],
18 | collapseHeightOnSearch: boolean,
19 | }
20 |
21 | const Scroll: FunctionComponent = ({emojisPerRow, emojiSize, numberScrollRows, focusedEmoji, emojiData, refVirtualList, handleClickInScroll, handleMouseInScroll, itemCount, itemRanges, collapseHeightOnSearch}) => {
22 |
23 | const [arrayOfRows, setArrayOfRows] = useState>({});
24 | const infiniteLoaderRef = useRef(null);
25 |
26 | // Keep track of previously focused emoji to avoid re-rendering all rows.
27 | const prevFocusedEmoji = useRef<{emoji: EmojiObject, row: number} | null>(null);
28 |
29 | // Reset arrayOfRows upon change in data or emojisPerRow.
30 | useEffect(function resetScrollState() {
31 | setArrayOfRows({});
32 | infiniteLoaderRef?.current.resetloadMoreItemsCache();
33 | prevFocusedEmoji.current = focusedEmoji; // focusedEmoji included in emojiData change render
34 | refVirtualList?.current.scrollToItem(0);
35 | loadMoreItems(0, Math.min(numberScrollRows + 10 - 1, itemRanges[itemRanges.length - 1].to)); // minimumBatchSize + threshold - 1
36 | }, [emojiData, emojisPerRow]);
37 |
38 | // Recompute the rows of the next and previous focusedEmoji upon change in focusedEmoji.
39 | useEffect(function resetRowsWithFocusedEmoji() {
40 | let prevEmoji = prevFocusedEmoji.current, nextEmoji = focusedEmoji;
41 | if (prevEmoji == nextEmoji) { return; }
42 | let rowsToUpdate = prevEmoji?.row == nextEmoji?.row ? [prevEmoji?.row] : [prevEmoji?.row, nextEmoji?.row]
43 | rowsToUpdate.forEach(row => row && loadMoreItems(row, row));
44 | prevFocusedEmoji.current = nextEmoji;
45 | nextEmoji?.row && refVirtualList.current?.scrollToItem(nextEmoji.row);
46 | }, [focusedEmoji]);
47 |
48 | const loadMoreItems = (startIndex: number, endIndex: number) => {
49 | const nextArrayOfRows = {}
50 | let i = startIndex, range: itemRange | undefined;
51 | while (i <= endIndex) {
52 |
53 | range = itemRanges.find(range => range.from <= i && i < range.to);
54 | if (range === undefined) break;
55 |
56 | for (let rowIndex = i; rowIndex < Math.min(range.to, endIndex + 1); rowIndex++) {
57 | if (rowIndex == range.from) {
58 | nextArrayOfRows[rowIndex] = {range.key}
59 | } else {
60 |
61 | const offset = rowIndex - range.from;
62 | const row = emojiData[range.key].slice((offset - 1) * emojisPerRow, offset * emojisPerRow)
63 |
64 | nextArrayOfRows[rowIndex] = (
65 |
66 | { row.map((emoji: EmojiObject, colIndex: number) => {
67 | const liProps = {
68 | key: emoji.unicode,
69 | onClick: handleClickInScroll(emoji, rowIndex),
70 | onMouseMove: handleMouseInScroll(emoji, rowIndex),
71 | role: "gridcell",
72 | "aria-rowindex": rowIndex + 1,
73 | "aria-colindex": colIndex + 1,
74 | tabIndex: -1,
75 | ...emoji === focusedEmoji?.emoji && {
76 | tabIndex: 0,
77 | ref: (li: HTMLLIElement) => focusedEmoji.focusOnRender && li?.focus({preventScroll: focusedEmoji.preventScroll}),
78 | }
79 | }
80 | const emojiProps = {
81 | emoji,
82 | ...emoji === focusedEmoji?.emoji && {
83 | className: "emoji-picker-emoji-focused",
84 | }
85 | }
86 | return (
87 | -
88 |
89 |
90 | )
91 | })
92 | }
93 |
94 | )
95 | }
96 | }
97 | i = range.to;
98 | }
99 | setArrayOfRows(prev => Object.assign({}, prev, nextArrayOfRows));
100 | }
101 |
102 | return (
103 | !!arrayOfRows[index]}
108 | minimumBatchSize={numberScrollRows}
109 | threshold={10}
110 | >
111 | {({onItemsRendered, ref}) => (
112 | {ref(list); refVirtualList && (refVirtualList.current = list);}}
115 | itemCount={itemCount}
116 | itemData={arrayOfRows}
117 | itemSize={emojiSize}
118 | height={collapseHeightOnSearch ? Math.min(itemCount * emojiSize + 9, numberScrollRows * emojiSize) : numberScrollRows * emojiSize}
119 | innerElementType={innerElementType}
120 | >
121 | {MemoizedRow}
122 |
123 | )}
124 |
125 | )
126 | }
127 |
128 | const MemoizedScroll = memo(Scroll, function ScrollPropsAreEqual(prevProps, nextProps) {
129 | return prevProps.focusedEmoji?.emoji == nextProps.focusedEmoji?.emoji
130 | && prevProps.emojiData == nextProps.emojiData
131 | && prevProps.collapseHeightOnSearch == nextProps.collapseHeightOnSearch
132 | && prevProps.emojiSize == nextProps.emojiSize
133 | && prevProps.emojisPerRow == nextProps.emojisPerRow;
134 | });
135 |
136 | export default MemoizedScroll;
137 |
138 | const VirtualRow: FunctionComponent<{index: number, style: CSSProperties, data}> = ({index, style, data}) => {
139 | return (
140 |
141 | {data[index]}
142 |
143 | )
144 | }
145 |
146 | /**
147 | * memoize rows of the virtualList, only re-rendering when changing in data[index]
148 | */
149 | const MemoizedRow = memo(VirtualRow, function compareRowProps(prevProps, nextProps) {
150 | const { style: prevStyle, data: prevData, index: prevIndex, ...prevRest } = prevProps;
151 | const { style: nextStyle, data: nextData, index: nextIndex, ...nextRest } = nextProps;
152 | return prevData[prevIndex] === nextData[nextIndex] && !shallowDiffer(prevStyle, nextStyle) && !shallowDiffer(prevRest, nextRest)
153 | });
154 |
155 |
156 | /**
157 | * adds padding to the bottom of virtual list
158 | * See: https://github.com/bvaughn/react-window#can-i-add-padding-to-the-top-and-bottom-of-a-list
159 | */
160 | const LIST_PADDING_SIZE = 9;
161 | const innerElementType = forwardRef(({style, ...props }: {style: CSSProperties}, ref: Ref) => (
162 | // @ts-ignore
163 |
166 | ));
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./EmojiPicker";
2 | export * from "./Emoji";
3 | export * from "./utils";
--------------------------------------------------------------------------------
/src/static.d.ts:
--------------------------------------------------------------------------------
1 | /* Use this file to declare any custom file extensions for importing */
2 | /* Use this folder to also add/extend a package d.ts file, if needed. */
3 |
4 | declare module '*.css';
5 |
6 | declare module '*.svg' {
7 | const ref: string;
8 | export default ref;
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export interface EmojiObject {
2 | unicode: string;
3 | name: string;
4 | keywords?: string[];
5 | }
6 |
7 | /**
8 | * Converts from unified to native representation of an emoji.
9 | * @param unified unified representation
10 | */
11 | export function unifiedToNative(unified: string) {
12 | const codePoints = unified.split('-').map(u => parseInt(u, 16));
13 | return String.fromCodePoint.apply(String, codePoints);
14 | }
15 |
16 | /**
17 | * Measures the pixel width of a scrollbar.
18 | * source: https://github.com/sonicdoe/measure-scrollbar.
19 | */
20 | export function measureScrollbar(): number {
21 | if (typeof document == 'undefined') return 0;
22 | const div = document.createElement('div');
23 | div.style.cssText = "width:100px; height:100px; overflow:scroll; position:absolute; top:-9999px";
24 | document.body.appendChild(div);
25 | const scrollbarWidth = div.offsetWidth - div.clientWidth;
26 | document.body.removeChild(div);
27 | return scrollbarWidth;
28 | }
29 |
30 | /**
31 | * Calculates the number of rows when key and array are flattened, along
32 | * with an array of ranges to map an index back to key.
33 | * @param data key array mapping
34 | * @param perRow number of elements to chunk array into
35 | */
36 | export type itemRange = { key: string; from: number; to: number; length: number }
37 | export function calcCountAndRange(data: Record, perRow: number) {
38 | let itemCount = 0, itemRanges: itemRange[] = [];
39 | Object.entries(data).forEach(([key, array]) => {
40 | if (array.length === 0) return;
41 | let from = itemCount, to = itemCount + 1 + Math.ceil(array.length / perRow);
42 | itemRanges.push({key, from, to, length: array.length});
43 | itemCount = to;
44 | })
45 | return {itemCount, itemRanges};
46 | }
47 |
48 | // Returns true if objects shallowly differ.
49 | export function shallowDiffer(prev: Object, next: Object): boolean {
50 | for (let attribute in prev) { if (!(attribute in next)) { return true; }}
51 | for (let attribute in next) { if (prev[attribute] !== next[attribute]) { return true; }}
52 | return false;
53 | }
54 |
55 | // Trailing throttle function.
56 | export function throttleIdleTask(callback: Function) {
57 | // @ts-ignore
58 | const idleHandler = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout;
59 | let running = false, argsFunc: any;
60 | return function throttled(...args: any[]) {
61 | argsFunc = args;
62 | if (running) { return; }
63 | running = true;
64 | idleHandler(() => {
65 | running = false;
66 | callback.apply(null, argsFunc);
67 | })
68 | }
69 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src/*"],
3 | "exclude": ["node_modules", "website"],
4 | "compilerOptions": {
5 | "baseUrl": "./",
6 | "noEmit": false,
7 | "sourceMap": true,
8 | "strict": false,
9 | "strictNullChecks": true,
10 | "noImplicitAny": false,
11 | "esModuleInterop": true,
12 | "moduleResolution": "node",
13 | "allowJs": true,
14 | "target": "es6",
15 | "lib": ["es2019", "dom"],
16 | "jsx": "react",
17 | "types": ["react"],
18 | "rootDir": "src",
19 | "outDir": "dist",
20 | "stripInternal": true,
21 | "removeComments": true,
22 | "declarationMap": true,
23 | "declaration": true,
24 | "declarationDir": "dist/types"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "routes": [
3 | {
4 | "src": "/_assets/(.*)",
5 | "headers": { "cache-control": "max-age=14400" },
6 | "dest": "/_assets/$1"
7 | }
8 | ]
9 | }
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | // vite.config.js
2 | import reactRefresh from '@vitejs/plugin-react-refresh'
3 |
4 | export default {
5 | plugins: [reactRefresh()],
6 | base: process.env["base"] || "/EmojiPicker/",
7 | json: {
8 | stringify: true, // faster parse for emoji data
9 | },
10 | build: {
11 | rollupOptions: {
12 | output: {
13 | entryFileNames: `assets/[name].js`,
14 | chunkFileNames: `assets/[name].js`,
15 | assetFileNames: `assets/[name].[ext]`
16 | }
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/website/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://rsms.me/inter/inter.css');
2 |
3 | :root {
4 | --sansSerif: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;
5 | }
6 |
7 | * {
8 | box-sizing: border-box;
9 | }
10 |
11 | html {
12 | background-color: rgb(255,255,255);
13 | color: rgb(29, 29, 31);
14 | font-family: 'Inter', var(--sansSerif);
15 | font-size: 18px;
16 | }
17 |
18 | body {
19 | margin: 0;
20 | }
21 |
22 | a {
23 | text-decoration: none;
24 | color: #06c;
25 | }
26 |
27 | a:hover, a:active, html a:focus {
28 | text-decoration: underline;
29 | }
30 |
31 | p {
32 | text-align: center;
33 | }
34 |
35 | h1 {
36 | font-size: 2.25rem;
37 | font-weight: 700;
38 | margin-bottom: 0;
39 | letter-spacing: -.01rem;
40 | }
41 |
42 | .emoji-picker {
43 | margin: 0.5rem 0;
44 | }
45 |
46 | input {
47 | font-size: 1rem;
48 | margin-top: 0.5rem; margin-bottom: 1rem;
49 | padding: 0.35rem 0.50rem;
50 | border-radius: 4px;
51 | border: 1px solid rgb(225, 225, 224);
52 | font-family: 'Inter', var(--sansSerif);
53 | }
54 |
55 | #notification {
56 | position: fixed;
57 | bottom: 20px;
58 | display: flex;
59 | justify-content: center;
60 | z-index: 1;
61 | pointer-events: none;
62 | }
63 |
64 | #notification div {
65 | background: black;
66 | color: white;
67 | font-size: 1rem;
68 | padding: 0.40rem 0.50rem;
69 | border-radius: 4px;
70 | opacity: 0.1;
71 | transition: 250ms all ease-in;
72 | transform: translate3d(0, 80px, 0);
73 | display: flex;
74 | align-items: center;
75 | }
76 |
77 | #notification.visible div {
78 | transform: translate3d(0, 0, 0);
79 | transition: 120ms all cubic-bezier(0.25, 0.47, 0.44, 0.93);
80 | opacity: 1;
81 | }
82 |
--------------------------------------------------------------------------------
/website/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useCallback, useState, ChangeEvent, KeyboardEvent } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import type { EmojiObject } from '../src/index';
4 | import { EmojiPicker, EmojiPickerRef, unifiedToNative, throttleIdleTask } from '../src/index';
5 | import EmojiData from "../data/twemoji.json"
6 | import './index.css';
7 |
8 | import "../src/EmojiPicker.css"
9 | import "../src/Emoji.css"
10 |
11 | const copyToClipboard = async (string: string) => {
12 | try {
13 | // Try to use the Async Clipboard API with fallback to the legacy approach.
14 | // @ts-ignore
15 | const {state} = await navigator.permissions.query({name: 'clipboard-write'});
16 | if (state !== 'granted') { throw new Error('Clipboard permission not granted'); }
17 | await navigator.clipboard.writeText(string);
18 | } catch {
19 | const textArea = document.createElement('textarea');
20 | textArea.value = string;
21 | document.body.appendChild(textArea);
22 | textArea.select();
23 | document.execCommand('copy');
24 | document.body.removeChild(textArea);
25 | }
26 | };
27 |
28 | function ExampleSetup() {
29 |
30 | const picker = useRef()
31 | const input = useRef()
32 |
33 | // need reference to same function to throttle
34 | const throttledQuery = useCallback(throttleIdleTask((query: string) => picker.current?.search(query)), [picker.current]);
35 |
36 | const inputProps = {
37 | ref: input,
38 | placeholder: "search-or-navigate",
39 | onChange: (event: ChangeEvent) => throttledQuery((event.target as HTMLInputElement).value.toLowerCase()),
40 | onKeyDown: (event: KeyboardEvent) => {
41 | if (!["Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
42 | picker.current.handleKeyDownScroll(event);
43 | if (event.key == "Enter" && !event.shiftKey) {
44 | picker.current.search("");
45 | input.current.value = "";
46 | }
47 | },
48 | }
49 |
50 | const onEmojiSelect = (emoji: EmojiObject) => {
51 | const nativeEmoji = unifiedToNative(emoji.unicode);
52 | copyToClipboard(nativeEmoji);
53 | notification.show(`Copied ${nativeEmoji} to clipboard`);
54 | console.log(emoji);
55 | }
56 |
57 | const emojiPickerProps = {
58 | ref: picker,
59 | emojiData: EmojiData,
60 | onEmojiSelect,
61 | showNavbar: true,
62 | showFooter: true,
63 | collapseHeightOnSearch: false,
64 | }
65 |
66 | /**
67 | * Adaptation of show-and-hide popup from https://rsms.me/inter/#charset for React hooks.
68 | * Ignore this if you're just using this website as an example of how to setup the emoji picker.
69 | */
70 | const [notification] = useState(() => {
71 |
72 | let timer = null
73 | let visible = false
74 |
75 | const show = (message) => {
76 | const el = document.querySelector('#notification') as HTMLDivElement;
77 | (el.firstChild as HTMLElement).innerText = message
78 | el.classList.add('visible')
79 | if (visible) {
80 | hide()
81 | setTimeout(() => show(message), 120)
82 | return
83 | }
84 | visible = true
85 | el.style.visibility = null
86 | clearTimeout(timer)
87 | timer = setTimeout(() => hide(), 1200)
88 | }
89 |
90 | const hide = () => {
91 | const el = document.querySelector('#notification') as HTMLDivElement;
92 | if (visible) {
93 | el.classList.remove('visible')
94 | visible = false
95 | el.style.visibility = 'hidden'
96 | }
97 | }
98 |
99 | return { show }
100 | })
101 |
102 | return (
103 |
104 |
Emoji Picker
105 |
A virtualized twemoji picker written in React and TypeScript.
106 |
107 |
108 |
source code →
109 |
112 |
113 | )
114 | }
115 |
116 | ReactDOM.render(, document.getElementById('example-setup'));
--------------------------------------------------------------------------------