Improve accessibility and touch support in dashboard

Enhances accessibility and usability for touch devices and Chromebooks by updating global styles, adding ARIA attributes, and optimizing component layouts. Introduces a new useIsTouchDevice hook, improves focus states, and increases viewport scalability. ChatDetail now supports better keyboard navigation and larger touch targets.
This commit is contained in:
NotII
2025-10-22 17:53:30 +01:00
parent bfc60012cf
commit 1fc29e6cbf
8 changed files with 205 additions and 21 deletions

View File

@@ -14,6 +14,7 @@ import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Downloa
import { getCookie, clientFetch } from "@/lib/api";
import { ImageViewerModal } from "@/components/modals/image-viewer-modal";
import BuyerOrderInfo from "./BuyerOrderInfo";
import { useIsTouchDevice } from "@/hooks/use-mobile";
interface Message {
_id: string;
@@ -98,6 +99,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const [selectedMessageIndex, setSelectedMessageIndex] = useState<number | null>(null);
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState<number | null>(null);
const seenMessageIdsRef = useRef<Set<string>>(new Set());
const isTouchDevice = useIsTouchDevice();
// Scroll to bottom utility functions
const scrollToBottom = () => {
@@ -358,6 +360,16 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
if (message.trim()) {
sendMessage(message);
}
} else if (e.key === 'Escape') {
// Clear the input on Escape
setMessage('');
} else if (e.key === 'ArrowUp' && message === '') {
// Load previous message on Arrow Up when input is empty
e.preventDefault();
const lastVendorMessage = [...messages].reverse().find(msg => msg.sender === 'vendor');
if (lastVendorMessage) {
setMessage(lastVendorMessage.content);
}
}
};
@@ -548,17 +560,37 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
return (
<div className="flex flex-col h-screen w-full relative">
<div className="border-b h-16 px-4 flex items-center justify-between bg-card z-10">
<div className={cn(
"border-b bg-card z-10 flex items-center justify-between",
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
)}>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}>
<ArrowLeft className="h-5 w-5" />
<Button
variant="ghost"
size="icon"
onClick={handleBackClick}
className={cn(
"transition-all duration-200",
isTouchDevice ? "h-12 w-12" : "h-10 w-10"
)}
aria-label="Go back to chats"
>
<ArrowLeft className={cn(
isTouchDevice ? "h-6 w-6" : "h-5 w-5"
)} />
</Button>
<div className="flex flex-col">
<h3 className="text-lg font-semibold">
<h3 className={cn(
"font-semibold",
isTouchDevice ? "text-xl" : "text-lg"
)}>
Chat with Customer {chat.buyerId}
</h3>
{chat.telegramUsername && (
<span className="text-sm text-muted-foreground">
<span className={cn(
"text-muted-foreground",
isTouchDevice ? "text-base" : "text-sm"
)}>
@{chat.telegramUsername}
</span>
)}
@@ -568,7 +600,13 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 pb-[80px]">
<div
className="flex-1 overflow-y-auto p-2 space-y-2 pb-[80px]"
role="log"
aria-label="Chat messages"
aria-live="polite"
aria-atomic="false"
>
{chat.messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<p className="text-muted-foreground">No messages yet. Send one to start the conversation.</p>
@@ -581,6 +619,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
"flex mb-4",
msg.sender === "vendor" ? "justify-end" : "justify-start"
)}
role="article"
aria-label={`Message from ${msg.sender === "vendor" ? "you" : "customer"}`}
>
<div
className={cn(
@@ -592,17 +632,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
>
<div className="flex items-center space-x-2 mb-1">
{msg.sender === "buyer" && (
<Avatar className="h-6 w-6">
<Avatar className="h-6 w-6" aria-label="Customer avatar">
<AvatarFallback className="text-xs">
{chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<span className="text-xs opacity-70">
<span className="text-xs opacity-70" aria-label={`Sent ${formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}`}>
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
</span>
</div>
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
<p className="whitespace-pre-wrap break-words" role="text">{msg.content}</p>
{/* Show attachments if any */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-2">
@@ -618,10 +658,19 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
key={`attachment-${attachmentIndex}`}
className="rounded-md overflow-hidden bg-background/20 p-1"
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)}
role="button"
tabIndex={0}
aria-label={`View image: ${fileName}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleImageClick(attachment, messageIndex, attachmentIndex);
}
}}
>
<div className="flex justify-between items-center mb-1">
<span className="text-xs opacity-70 flex items-center">
<ImageIcon className="h-3 w-3 mr-1" />
<ImageIcon className="h-3 w-3 mr-1" aria-hidden="true" />
{fileName}
</span>
<a
@@ -631,8 +680,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
rel="noopener noreferrer"
className="text-xs opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()}
aria-label={`Download image: ${fileName}`}
>
<Download className="h-3 w-3" />
<Download className="h-3 w-3" aria-hidden="true" />
</a>
</div>
<img
@@ -647,11 +697,11 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
) : (
// Render file attachment
<div key={`attachment-${attachmentIndex}`} className="flex items-center bg-background/20 rounded-md p-2 hover:bg-background/30 transition-colors">
<div className="mr-2">
<div className="mr-2" aria-hidden="true">
{getFileIcon(attachment)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate font-medium">
<div className="text-sm truncate font-medium" aria-label={`File: ${fileName}`}>
{fileName}
</div>
</div>
@@ -661,8 +711,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
target="_blank"
rel="noopener noreferrer"
className="ml-2 p-1 rounded-sm hover:bg-background/50"
aria-label={`Download file: ${fileName}`}
>
<Download className="h-4 w-4" />
<Download className="h-4 w-4" aria-hidden="true" />
</a>
</div>
);
@@ -676,21 +727,44 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<div ref={messagesEndRef} />
</div>
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background p-4">
<div className={cn(
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
isTouchDevice ? "p-3" : "p-4"
)}>
<form onSubmit={handleSendMessage} className="flex space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={sending}
className="flex-1"
className={cn(
"flex-1 text-base transition-all duration-200",
isTouchDevice ? "min-h-[48px] text-lg" : "min-h-[44px]"
)}
onKeyDown={handleKeyDown}
autoFocus
aria-label="Message input"
aria-describedby="message-help"
role="textbox"
autoComplete="off"
spellCheck="true"
maxLength={2000}
/>
<Button type="submit" disabled={sending || !message.trim()}>
<Button
type="submit"
disabled={sending || !message.trim()}
aria-label={sending ? "Sending message" : "Send message"}
className={cn(
"transition-all duration-200",
isTouchDevice ? "min-h-[48px] min-w-[48px]" : "min-h-[44px] min-w-[44px]"
)}
>
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</form>
<div id="message-help" className="sr-only">
Press Enter to send message, Shift+Enter for new line, Escape to clear, Arrow Up to load previous message. Maximum 2000 characters.
</div>
</div>
{/* Update the image viewer modal */}

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm transition-colors duration-200 hover:border-input/80 focus:border-ring",
className
)}
ref={ref}