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:
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#000000" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#D53F8C" },
|
||||
|
||||
@@ -10,6 +10,89 @@ body {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Better focus states for keyboard navigation */
|
||||
.focus-visible:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Improved touch targets for mobile/Chromebook */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Better contrast for Chromebook displays */
|
||||
@media (prefers-contrast: high) {
|
||||
.border-input {
|
||||
border-color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Chromebook and touch device optimizations */
|
||||
@media (pointer: coarse) {
|
||||
.touch-target {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
/* Larger touch targets for interactive elements */
|
||||
button, input, textarea, [role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Better focus indicators for keyboard navigation */
|
||||
.focus-visible:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
|
||||
}
|
||||
|
||||
/* Improved scrolling for touch devices */
|
||||
.overflow-y-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Enhanced contrast for better visibility */
|
||||
.text-muted-foreground {
|
||||
color: hsl(var(--muted-foreground) / 0.8);
|
||||
}
|
||||
|
||||
/* Better button contrast */
|
||||
button:not(:disabled):hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Improved focus visibility */
|
||||
input:focus, textarea:focus, button:focus {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Better message bubble contrast */
|
||||
.bg-primary {
|
||||
background-color: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.bg-muted {
|
||||
background-color: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -55,7 +55,8 @@ export const metadata: Metadata = {
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#000000" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#D53F8C" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,3 +17,25 @@ export function useIsMobile() {
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
|
||||
export function useIsTouchDevice() {
|
||||
const [isTouch, setIsTouch] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkTouch = () => {
|
||||
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
setIsTouch(hasTouch)
|
||||
}
|
||||
|
||||
checkTouch()
|
||||
|
||||
// Listen for changes in touch capability
|
||||
const mediaQuery = window.matchMedia('(pointer: coarse)')
|
||||
const handleChange = () => checkTouch()
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return !!isTouch
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"commitHash": "03a2e37",
|
||||
"buildTime": "2025-10-16T20:09:52.215Z"
|
||||
"commitHash": "bfc6001",
|
||||
"buildTime": "2025-10-22T16:52:33.065Z"
|
||||
}
|
||||
Reference in New Issue
Block a user