feat: accessibility for most components
This commit is contained in:
parent
ef521dfc47
commit
bc95fb10fc
13 changed files with 226 additions and 125 deletions
|
|
@ -25,11 +25,16 @@ function ChatInput({ sendMessage }: ChatInputProps) {
|
|||
|
||||
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="Message"
|
||||
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"
|
||||
/>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ function ChatMenu() {
|
|||
const { actions, state } = useChatState();
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 w-full">
|
||||
<div aria-label="Chat navigation" className="flex gap-4 w-full">
|
||||
<button
|
||||
onClick={() => actions.setMenu("chat")}
|
||||
aria-pressed={state.selectedMenu === "chat"}
|
||||
className={`w-1/2 py-4 ${
|
||||
state.selectedMenu === "chat" ? "bg-white" : "bg-gray-100"
|
||||
}`}
|
||||
|
|
@ -16,6 +17,7 @@ function ChatMenu() {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => actions.setMenu("participants")}
|
||||
aria-pressed={state.selectedMenu === "participants"}
|
||||
className={`w-1/2 py-4 ${
|
||||
state.selectedMenu === "participants" ? "bg-white" : "bg-gray-100"
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { ChatMessage } from "../../../shared";
|
||||
|
||||
function ChatMessageDisplay({
|
||||
message,
|
||||
userId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
userId: string | undefined;
|
||||
onDelete: (messageId: string) => void;
|
||||
onEdit: (messageId: string, newContent: string) => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
const isOwn = userId === message.author.userId;
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (editContent.trim() !== message.content) {
|
||||
onEdit(message.id, editContent);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditContent(message.content);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={message.id} className={`mb-4 ${isOwn ? "ml-auto" : "mr-auto"}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 text-sm text-gray-600 mb-1
|
||||
${isOwn ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isOwn && (
|
||||
<span className="font-bold text-gray-700">{message.author.name}</span>
|
||||
)}
|
||||
<span className="font-medium opacity-45">{timestamp}</span>
|
||||
{isOwn && (
|
||||
<span className="font-bold text-gray-700">{message.author.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{isEditing ? (
|
||||
<div className={`max-w-[80%] ${isOwn ? "ml-auto" : "mr-auto"}`}>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<div
|
||||
className={`flex gap-2 mt-2 ${isOwn ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200
|
||||
${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
{isOwn && !isEditing && (
|
||||
<div
|
||||
className={`flex gap-2 mt-0.5 text-[10px]
|
||||
${isOwn ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-blue-600 hover:text-blue-800 active:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span className="text-gray-400">•</span>
|
||||
<button
|
||||
onClick={() => onDelete?.(message.id)}
|
||||
className="text-red-600 hover:text-red-800 active:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChatMessageDisplay);
|
||||
81
client/src/components/ChatMessage/ChatMessageDisplay.tsx
Normal file
81
client/src/components/ChatMessage/ChatMessageDisplay.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useState } from "react";
|
||||
import { ChatMessage } from "../../../../shared";
|
||||
import MessageHeader from "./MessageHeader";
|
||||
import MessageEditForm from "./MessageEditForm";
|
||||
import MessageActions from "./MessageActions";
|
||||
|
||||
function ChatMessageDisplay({
|
||||
message,
|
||||
userId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
userId: string | undefined;
|
||||
onDelete: (messageId: string) => void;
|
||||
onEdit: (messageId: string, newContent: string) => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(message.content);
|
||||
const isOwn = userId === message.author.userId;
|
||||
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (editContent.trim() !== message.content) {
|
||||
onEdit(message.id, editContent);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditContent(message.content);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
key={message.id}
|
||||
className={`mb-4 ${isOwn ? "ml-auto" : "mr-auto"}`}
|
||||
aria-label={`Message from ${message.author.name} on ${timestamp}`}
|
||||
>
|
||||
<MessageHeader
|
||||
author={message.author.name}
|
||||
timestamp={timestamp}
|
||||
isOwn={isOwn}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{isEditing ? (
|
||||
<MessageEditForm
|
||||
editContent={editContent}
|
||||
setEditContent={setEditContent}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200
|
||||
${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`}
|
||||
aria-label="Message content"
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
{isOwn && (
|
||||
<MessageActions
|
||||
onEdit={() => setIsEditing(true)}
|
||||
onDelete={() => onDelete(message.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChatMessageDisplay);
|
||||
34
client/src/components/ChatMessage/MessageActions.tsx
Normal file
34
client/src/components/ChatMessage/MessageActions.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const MessageActions = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex gap-2 mt-0.5 text-[10px] justify-end`}
|
||||
aria-label="Message actions"
|
||||
>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-blue-600 hover:text-blue-800 active:text-blue-900"
|
||||
aria-label="Edit message"
|
||||
title="Edit message"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<span className="text-gray-400" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-red-600 hover:text-red-800 active:text-red-900"
|
||||
aria-label="Delete message"
|
||||
title="Delete message"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MessageActions;
|
||||
44
client/src/components/ChatMessage/MessageEditForm.tsx
Normal file
44
client/src/components/ChatMessage/MessageEditForm.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const MessageEditForm = ({
|
||||
editContent,
|
||||
setEditContent,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
editContent: string;
|
||||
setEditContent: (content: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) => (
|
||||
<div className={`max-w-[80%] ml-auto`}>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
aria-label="Edit message content"
|
||||
/>
|
||||
<div
|
||||
className={`flex gap-2 mt-2 justify-end`}
|
||||
role="group"
|
||||
aria-label="Edit message actions"
|
||||
>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
|
||||
aria-label="Save changes"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300"
|
||||
aria-label="Cancel editing"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MessageEditForm;
|
||||
20
client/src/components/ChatMessage/MessageHeader.tsx
Normal file
20
client/src/components/ChatMessage/MessageHeader.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const MessageHeader = ({
|
||||
author,
|
||||
timestamp,
|
||||
isOwn,
|
||||
}: {
|
||||
author: string;
|
||||
timestamp: string;
|
||||
isOwn: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-2 text-sm text-gray-600 mb-1
|
||||
${isOwn ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isOwn && <span className="font-bold text-gray-700">{author}</span>}
|
||||
<span className="font-medium opacity-45">{timestamp}</span>
|
||||
{isOwn && <span className="font-bold text-gray-700">{author}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MessageHeader;
|
||||
|
|
@ -16,7 +16,7 @@ function Connect({ onConnect }: ConnectProps) {
|
|||
|
||||
actions.setUser({
|
||||
name: name,
|
||||
userId: "", // leave userId empty for now, it is server generated
|
||||
userId: "", // leave userId empty for now, it is server generated and inserted by the REGISTRATION_CONFIRMED message
|
||||
});
|
||||
|
||||
onConnect(name);
|
||||
|
|
@ -29,16 +29,26 @@ function Connect({ onConnect }: ConnectProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 mt-4">
|
||||
<div className="flex gap-4 mt-4" role="form" aria-label="Connect to chat">
|
||||
<label htmlFor="username-input" className="sr-only">
|
||||
Enter your name
|
||||
</label>
|
||||
<input
|
||||
id="username-input"
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
aria-required="true"
|
||||
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"
|
||||
/>
|
||||
<button onClick={handleConnect} disabled={!name} className="btn">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={!name}
|
||||
className="btn"
|
||||
aria-label="Connect to chat room"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ function Layout({ children }: LayoutProps) {
|
|||
return (
|
||||
<>
|
||||
<main className="min-h-screen p-4">
|
||||
<header className="top-0 w-full bg-white">
|
||||
<header className="top-0 w-full bg-white" role="banner">
|
||||
<h1 className="text-3xl text-center">Simple Chat</h1>
|
||||
</header>
|
||||
<div className="max-w-2xl md:p-8 m-auto">{children}</div>
|
||||
<div className="max-w-2xl md:p-8 m-auto" role="main">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
ServerMessage,
|
||||
} from "../../../shared";
|
||||
import { useChatState } from "../contexts/ChatContext";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay";
|
||||
|
||||
interface MessageDisplayProps {
|
||||
sendMessage: (message: ClientMessage) => void;
|
||||
|
|
@ -56,7 +56,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
|
|||
switch (message.type) {
|
||||
case "CHAT_MESSAGE":
|
||||
return (
|
||||
<ChatMessage
|
||||
<ChatMessageDisplay
|
||||
message={message.payload}
|
||||
userId={userId}
|
||||
key={message.payload.id}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ function ParticipantList() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ul>
|
||||
<ul role="list" aria-label="Participants list">
|
||||
{state.participants.map((participant) => (
|
||||
<li key={participant.userId} className="p-4 border-b border-gray-200">
|
||||
{participant.name}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function Home() {
|
|||
type="button"
|
||||
onClick={onClickCreateRoom}
|
||||
className="btn w-full"
|
||||
aria-label="Create new chat room"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
|
|
@ -47,15 +48,18 @@ function Home() {
|
|||
<input
|
||||
id="roomId"
|
||||
type="text"
|
||||
placeholder="Room ID"
|
||||
placeholder="Enter Room ID"
|
||||
value={roomId}
|
||||
onChange={(e) => setRoomId(e.target.value)}
|
||||
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"
|
||||
aria-required="true"
|
||||
aria-invalid={!roomId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClickJoinRoom}
|
||||
disabled={!roomId}
|
||||
aria-disabled={!roomId}
|
||||
className="w-full btn bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Join
|
||||
|
|
|
|||
|
|
@ -30,16 +30,24 @@ function Room() {
|
|||
<Layout>
|
||||
<section className="w-full">
|
||||
<RoomHeader roomId={roomId!} isConnected={isConnected} />
|
||||
<nav aria-label="Chat navigation">
|
||||
<ChatMenu />
|
||||
</nav>
|
||||
|
||||
<ChatMenu />
|
||||
{state.selectedMenu === "chat" && (
|
||||
<>
|
||||
<section aria-label="Chat Section" className="chat-section">
|
||||
<ChatMessages sendMessage={sendMessage} />
|
||||
<ChatInput sendMessage={sendMessage} />
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
{state.selectedMenu === "participants" && (
|
||||
<section
|
||||
aria-label="Participants list"
|
||||
className="participants-section"
|
||||
>
|
||||
<ParticipantList />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{state.selectedMenu === "participants" && <ParticipantList />}
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue