feat: accessibility for most components

This commit is contained in:
Alexander Daichendt 2025-02-13 14:11:58 +01:00
parent ef521dfc47
commit bc95fb10fc
13 changed files with 226 additions and 125 deletions

View file

@ -25,11 +25,16 @@ function ChatInput({ sendMessage }: ChatInputProps) {
return ( return (
<form onSubmit={handleSubmit} className="mt-4"> <form onSubmit={handleSubmit} className="mt-4">
<label htmlFor="chat-input" className="sr-only">
Type a message
</label>
<input <input
id="chat-input"
type="text" type="text"
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} 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" 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> </form>

View file

@ -5,9 +5,10 @@ function ChatMenu() {
const { actions, state } = useChatState(); const { actions, state } = useChatState();
return ( return (
<div className="flex gap-4 w-full"> <div aria-label="Chat navigation" className="flex gap-4 w-full">
<button <button
onClick={() => actions.setMenu("chat")} onClick={() => actions.setMenu("chat")}
aria-pressed={state.selectedMenu === "chat"}
className={`w-1/2 py-4 ${ className={`w-1/2 py-4 ${
state.selectedMenu === "chat" ? "bg-white" : "bg-gray-100" state.selectedMenu === "chat" ? "bg-white" : "bg-gray-100"
}`} }`}
@ -16,6 +17,7 @@ function ChatMenu() {
</button> </button>
<button <button
onClick={() => actions.setMenu("participants")} onClick={() => actions.setMenu("participants")}
aria-pressed={state.selectedMenu === "participants"}
className={`w-1/2 py-4 ${ className={`w-1/2 py-4 ${
state.selectedMenu === "participants" ? "bg-white" : "bg-gray-100" state.selectedMenu === "participants" ? "bg-white" : "bg-gray-100"
}`} }`}

View file

@ -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);

View 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);

View 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;

View 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;

View 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;

View file

@ -16,7 +16,7 @@ function Connect({ onConnect }: ConnectProps) {
actions.setUser({ actions.setUser({
name: name, 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); onConnect(name);
@ -29,16 +29,26 @@ function Connect({ onConnect }: ConnectProps) {
} }
return ( 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 <input
id="username-input"
type="text" type="text"
placeholder="Enter your name" placeholder="Enter your name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyPress} 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" 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 Connect
</button> </button>
</div> </div>

View file

@ -6,10 +6,12 @@ function Layout({ children }: LayoutProps) {
return ( return (
<> <>
<main className="min-h-screen p-4"> <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> <h1 className="text-3xl text-center">Simple Chat</h1>
</header> </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> </main>
</> </>
); );

View file

@ -6,7 +6,7 @@ import {
ServerMessage, ServerMessage,
} from "../../../shared"; } from "../../../shared";
import { useChatState } from "../contexts/ChatContext"; import { useChatState } from "../contexts/ChatContext";
import ChatMessage from "./ChatMessage"; import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay";
interface MessageDisplayProps { interface MessageDisplayProps {
sendMessage: (message: ClientMessage) => void; sendMessage: (message: ClientMessage) => void;
@ -56,7 +56,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
switch (message.type) { switch (message.type) {
case "CHAT_MESSAGE": case "CHAT_MESSAGE":
return ( return (
<ChatMessage <ChatMessageDisplay
message={message.payload} message={message.payload}
userId={userId} userId={userId}
key={message.payload.id} key={message.payload.id}

View file

@ -5,7 +5,7 @@ function ParticipantList() {
return ( return (
<> <>
<ul> <ul role="list" aria-label="Participants list">
{state.participants.map((participant) => ( {state.participants.map((participant) => (
<li key={participant.userId} className="p-4 border-b border-gray-200"> <li key={participant.userId} className="p-4 border-b border-gray-200">
{participant.name} {participant.name}

View file

@ -29,6 +29,7 @@ function Home() {
type="button" type="button"
onClick={onClickCreateRoom} onClick={onClickCreateRoom}
className="btn w-full" className="btn w-full"
aria-label="Create new chat room"
> >
Create Create
</button> </button>
@ -47,15 +48,18 @@ function Home() {
<input <input
id="roomId" id="roomId"
type="text" type="text"
placeholder="Room ID" placeholder="Enter Room ID"
value={roomId} value={roomId}
onChange={(e) => setRoomId(e.target.value)} 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" 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 <button
type="button" type="button"
onClick={onClickJoinRoom} onClick={onClickJoinRoom}
disabled={!roomId} disabled={!roomId}
aria-disabled={!roomId}
className="w-full btn bg-green-600 hover:bg-green-700" className="w-full btn bg-green-600 hover:bg-green-700"
> >
Join Join

View file

@ -30,16 +30,24 @@ function Room() {
<Layout> <Layout>
<section className="w-full"> <section className="w-full">
<RoomHeader roomId={roomId!} isConnected={isConnected} /> <RoomHeader roomId={roomId!} isConnected={isConnected} />
<nav aria-label="Chat navigation">
<ChatMenu />
</nav>
<ChatMenu />
{state.selectedMenu === "chat" && ( {state.selectedMenu === "chat" && (
<> <section aria-label="Chat Section" className="chat-section">
<ChatMessages sendMessage={sendMessage} /> <ChatMessages sendMessage={sendMessage} />
<ChatInput sendMessage={sendMessage} /> <ChatInput sendMessage={sendMessage} />
</> </section>
)}
{state.selectedMenu === "participants" && (
<section
aria-label="Participants list"
className="participants-section"
>
<ParticipantList />
</section>
)} )}
{state.selectedMenu === "participants" && <ParticipantList />}
</section> </section>
</Layout> </Layout>
); );