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:
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user