feat: add emoji picker

This commit is contained in:
Alexander Daichendt 2025-02-14 10:56:39 +01:00
parent 963a37292d
commit 1d0b5ca55e
6 changed files with 122 additions and 19 deletions

View file

@ -8,7 +8,6 @@
"name": "Alexander Daichendt",
"url": "https://daichendt.one"
},
"packageManager": "pnpm@10.3.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@ -19,6 +18,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.0.6",
"emoji-picker-react": "^4.12.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",

19
client/pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.6
version: 4.0.6(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1))
emoji-picker-react:
specifier: ^4.12.0
version: 4.12.0(react@19.0.0)
react:
specifier: ^19.0.0
version: 19.0.0
@ -920,6 +923,12 @@ packages:
electron-to-chromium@1.5.97:
resolution: {integrity: sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==}
emoji-picker-react@4.12.0:
resolution: {integrity: sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
@ -1032,6 +1041,9 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@ -2503,6 +2515,11 @@ snapshots:
electron-to-chromium@1.5.97: {}
emoji-picker-react@4.12.0(react@19.0.0):
dependencies:
flairup: 1.0.0
react: 19.0.0
enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
@ -2658,6 +2675,8 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
flairup@1.0.0: {}
flat-cache@4.0.1:
dependencies:
flatted: 3.3.2

View file

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { ClientChatMessage, ClientMessage } from "../../../shared";
import EmojiPicker from "./EmojiPicker";
interface ChatInputProps {
sendMessage: (message: ClientMessage) => void;
@ -23,20 +24,36 @@ function ChatInput({ sendMessage }: ChatInputProps) {
setMessage("");
}
const handleEmojiSelect = (emoji: string) => {
setMessage((prevMessage) => prevMessage + emoji);
};
// this is required as otherwise pressing enter will open the emoji picker and not submit the message
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit} className="mt-4">
<label htmlFor="chat-input" className="sr-only">
Type a message
</label>
<input
id="chat-input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message" // this message is more descriptive than a simple "Message", therefore better for accessibility
aria-label="Chat message input"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<div className="flex items-center gap-2">
<label htmlFor="chat-input" className="sr-only">
Type a message
</label>
<input
id="chat-input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message"
aria-label="Chat message input"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<EmojiPicker onEmojiClick={handleEmojiSelect} />
</div>
</form>
);
}

View file

@ -0,0 +1,68 @@
import { EmojiClickData, EmojiStyle } from "emoji-picker-react";
import EmojiPickerComponent from "emoji-picker-react";
import { useState, useRef, useEffect } from "react";
interface EmojiPickerProps {
onEmojiClick: (emoji: string) => void;
}
function EmojiPicker({ onEmojiClick }: EmojiPickerProps) {
const [showPicker, setShowPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const controller = new AbortController(); // cool usage of AbortController to handle listener cleanup
function handleClickOutside(event: MouseEvent) {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setShowPicker(false);
}
}
document.addEventListener("mousedown", handleClickOutside, {
signal: controller.signal,
});
return () => controller.abort();
}, []);
const handleEmojiClick = (emojiData: EmojiClickData) => {
onEmojiClick(emojiData.emoji);
setShowPicker(false);
};
const handleToggleEmojiPicker = (
event: React.MouseEvent<HTMLButtonElement>,
) => {
// required to prevent default behavior of button click, which would trigger a form submit
event.preventDefault();
setShowPicker(!showPicker);
};
return (
<div ref={pickerRef} className="relative">
<button
role="button"
onClick={handleToggleEmojiPicker}
className="p-2 text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label="Open emoji picker"
>
😊
</button>
{showPicker && (
<div className="absolute bottom-12 right-0 z-50">
<EmojiPickerComponent
lazyLoadEmojis
emojiStyle={EmojiStyle.NATIVE} // uses native emoji style of the operating system, otherwise the displayed emoji will differ from what is shown in the picker
onEmojiClick={handleEmojiClick}
/>
</div>
)}
</div>
);
}
export default EmojiPicker;