fix: unclickable save button due to overlap with virtualized list item

This commit is contained in:
Alexander Daichendt 2025-02-19 23:29:14 +01:00
parent 7913ad17c8
commit 2282560481
4 changed files with 31 additions and 92 deletions

View file

@ -22,8 +22,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
"react-virtualized-auto-sizer": "^1.0.25",
"react-window": "^1.8.11",
"tailwindcss": "^4.0.6"
},
"devDependencies": {
@ -32,7 +30,6 @@
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",

46
client/pnpm-lock.yaml generated
View file

@ -23,12 +23,6 @@ importers:
react-router-dom:
specifier: ^7.1.5
version: 7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-virtualized-auto-sizer:
specifier: ^1.0.25
version: 1.0.25(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-window:
specifier: ^1.8.11
version: 1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwindcss:
specifier: ^4.0.6
version: 4.0.6
@ -48,9 +42,6 @@ importers:
'@types/react-dom':
specifier: ^19.0.3
version: 19.0.3(@types/react@19.0.8)
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.3.4(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1))
@ -668,9 +659,6 @@ packages:
peerDependencies:
'@types/react': ^19.0.0
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
'@types/react@19.0.8':
resolution: {integrity: sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==}
@ -1280,9 +1268,6 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -1416,19 +1401,6 @@ packages:
react-dom:
optional: true
react-virtualized-auto-sizer@1.0.25:
resolution: {integrity: sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
@ -2233,10 +2205,6 @@ snapshots:
dependencies:
'@types/react': 19.0.8
'@types/react-window@1.8.8':
dependencies:
'@types/react': 19.0.8
'@types/react@19.0.8':
dependencies:
csstype: 3.1.3
@ -2890,8 +2858,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
memoize-one@5.2.1: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@ -3009,18 +2975,6 @@ snapshots:
optionalDependencies:
react-dom: 19.0.0(react@19.0.0)
react-virtualized-auto-sizer@1.0.25(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-window@1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.7
memoize-one: 5.2.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@19.0.0: {}
redent@3.0.0:

View file

@ -59,7 +59,7 @@ function ChatMessageDisplay({
) : (
<>
<div
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200 whitespace-pre-wrap
${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`}
aria-label="Message content"
>

View file

@ -1,10 +1,9 @@
import React, { useEffect, useRef } from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import {
ClientDeleteMessage,
ClientEditMessage,
ClientMessage,
ServerMessage,
} from "../../../shared";
import { useChatState } from "../contexts/ChatContext";
import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay";
@ -16,7 +15,10 @@ interface MessageDisplayProps {
function MessageDisplay({ sendMessage }: MessageDisplayProps) {
const { state } = useChatState();
const userId = state.currentUser?.userId;
const listRef = useRef<List>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to
// this counter is used to generate unique keys for join/leave messages. This is not ideal and with more time I'd introduce server-side message IDs for all messages, not just chat messages
let messageCounter = 0;
const onDelete = (messageId: string) => {
const deleteMessage: ClientDeleteMessage = {
@ -38,66 +40,52 @@ function MessageDisplay({ sendMessage }: MessageDisplayProps) {
};
useEffect(() => {
if (listRef.current) {
// Scroll to bottom when messages change
listRef.current.scrollToItem(state.messages.length - 1);
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
scrollToBottom();
}, [state.messages]);
const Row = ({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => {
const message = state.messages[index];
function renderMessage(message: ServerMessage) {
switch (message.type) {
case "CHAT_MESSAGE":
return (
<div style={style}>
<ChatMessageDisplay
key={message.payload.id}
message={message.payload}
userId={userId}
onDelete={onDelete}
onEdit={onEdit}
/>
</div>
);
case "USER_JOINED":
return (
<div style={style} className="text-center text-gray-500 my-2">
<div
className="text-center text-gray-500 my-2"
key={`join-${message.payload.user.name}-${messageCounter++}`}
>
{message.payload.user.name} joined!
</div>
);
case "USER_LEFT":
return (
<div style={style} className="text-center text-gray-500 my-2">
<div
className="text-center text-gray-500 my-2"
key={`leave-${message.payload.user.name}-${messageCounter++}`}
>
{message.payload.user.name} left!
</div>
);
default:
return null;
}
};
}
return (
<div className="h-[calc(100vh-theme('spacing.64'))]">
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
height={height}
width={width}
itemCount={state.messages.length}
itemSize={90} // sadly, this will make the system messages really tall. apparently its non trivial to fix that
overscanCount={5}
>
{Row}
</List>
)}
</AutoSizer>
<div className="min-h-[calc(100vh-theme('spacing.48'))] overflow-y-auto p-4">
{state.messages.map(renderMessage)}
<div ref={messagesEndRef} />
</div>
);
}