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

@ -11,6 +11,7 @@ A real-time chat application built with React, TypeScript, and Bun, featuring We
- Message editing and deletion - Message editing and deletion
- Participant list - Participant list
- Accessibility-focused UI - Accessibility-focused UI
- Emoji picker
## Technology Stack ## Technology Stack
@ -30,8 +31,8 @@ A real-time chat application built with React, TypeScript, and Bun, featuring We
### Prerequisites ### Prerequisites
- Node.js (v22 or later) - Node.js (v22 or later)
- Bun runtime - Bun runtime (1.2.1)
- pnpm package manager - pnpm package manager (tested with 10.4)
### Installation ### Installation
@ -65,7 +66,7 @@ bun run dev
``` ```
4. Open http://localhost:5173 in your browser 4. Open http://localhost:5173 in your browser
5. A refresh might be necessary after starting the dev server. Might be something wrong with this vite version.
## Usage ## Usage
@ -75,7 +76,6 @@ bun run dev
## Testing ## Testing
Run the test suite: Run the test suite:
```bash ```bash
@ -91,7 +91,7 @@ pnpm test
- no user authentication - no user authentication
- Client Websockets should be more robust, i.e. reconnecting - Client Websockets should be more robust, i.e. reconnecting
- Client state does not persist on refresh - Client state does not persist on refresh
- Performance: when sending a very large amount of messages, like 100000, the browser will freeze. This is not due to bad rendering per se, but the websocket message ingestion. Fixing this would be quite easy with a server-sided message debounce. - Performance: when sending a very large amount of messages, like 100000, the browser will freeze. This is not due to bad rendering per se, but the websocket message ingestion. Fixing this would be quite easy with a server-sided message debounce and batching.
- Test coverage very limited: only a single component is tested - Test coverage very limited: only a single component is tested
- Keyboard navigation and focus management could be improved - Keyboard navigation and focus management could be improved
- Entire deployment process is missing, i.e. Dockerfiles, CI/CD, reverse proxy - Entire deployment process is missing, i.e. Dockerfiles, CI/CD, reverse proxy

View file

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

19
client/pnpm-lock.yaml generated
View file

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

View file

@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ClientChatMessage, ClientMessage } from "../../../shared"; import { ClientChatMessage, ClientMessage } from "../../../shared";
import EmojiPicker from "./EmojiPicker";
interface ChatInputProps { interface ChatInputProps {
sendMessage: (message: ClientMessage) => void; sendMessage: (message: ClientMessage) => void;
@ -23,8 +24,20 @@ function ChatInput({ sendMessage }: ChatInputProps) {
setMessage(""); 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 ( return (
<form onSubmit={handleSubmit} className="mt-4"> <form onSubmit={handleSubmit} className="mt-4">
<div className="flex items-center gap-2">
<label htmlFor="chat-input" className="sr-only"> <label htmlFor="chat-input" className="sr-only">
Type a message Type a message
</label> </label>
@ -33,10 +46,14 @@ function ChatInput({ sendMessage }: ChatInputProps) {
type="text" type="text"
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message" // this message is more descriptive than a simple "Message", therefore better for accessibility onKeyDown={handleKeyDown}
placeholder="Type a message"
aria-label="Chat message input" 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" 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> </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;

View file

@ -8,7 +8,6 @@
"name": "Alexander Daichendt", "name": "Alexander Daichendt",
"url": "https://daichendt.one" "url": "https://daichendt.one"
}, },
"packageManager": "bun@1.2.1",
"scripts": { "scripts": {
"dev": "bun --hot src/index.ts" "dev": "bun --hot src/index.ts"
}, },