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

@@ -1,3 +1,5 @@
"use client";
import React from "react"; import React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View File

@@ -8,6 +8,8 @@ export const metadata: Metadata = {
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: dark)", color: "#000000" }, { media: "(prefers-color-scheme: dark)", color: "#000000" },
{ media: "(prefers-color-scheme: light)", color: "#D53F8C" }, { media: "(prefers-color-scheme: light)", color: "#D53F8C" },

View File

@@ -10,6 +10,89 @@ body {
.text-balance { .text-balance {
text-wrap: 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 { @layer base {

View File

@@ -55,7 +55,8 @@ export const metadata: Metadata = {
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 5,
userScalable: true,
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: dark)", color: "#000000" }, { media: "(prefers-color-scheme: dark)", color: "#000000" },
{ media: "(prefers-color-scheme: light)", color: "#D53F8C" }, { media: "(prefers-color-scheme: light)", color: "#D53F8C" },

View File

@@ -14,6 +14,7 @@ import { ArrowLeft, Send, RefreshCw, File, FileText, Image as ImageIcon, Downloa
import { getCookie, clientFetch } from "@/lib/api"; import { getCookie, clientFetch } from "@/lib/api";
import { ImageViewerModal } from "@/components/modals/image-viewer-modal"; import { ImageViewerModal } from "@/components/modals/image-viewer-modal";
import BuyerOrderInfo from "./BuyerOrderInfo"; import BuyerOrderInfo from "./BuyerOrderInfo";
import { useIsTouchDevice } from "@/hooks/use-mobile";
interface Message { interface Message {
_id: string; _id: string;
@@ -98,6 +99,7 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
const [selectedMessageIndex, setSelectedMessageIndex] = useState<number | null>(null); const [selectedMessageIndex, setSelectedMessageIndex] = useState<number | null>(null);
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState<number | null>(null); const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState<number | null>(null);
const seenMessageIdsRef = useRef<Set<string>>(new Set()); const seenMessageIdsRef = useRef<Set<string>>(new Set());
const isTouchDevice = useIsTouchDevice();
// Scroll to bottom utility functions // Scroll to bottom utility functions
const scrollToBottom = () => { const scrollToBottom = () => {
@@ -358,6 +360,16 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
if (message.trim()) { if (message.trim()) {
sendMessage(message); 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 ( return (
<div className="flex flex-col h-screen w-full relative"> <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"> <div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleBackClick}> <Button
<ArrowLeft className="h-5 w-5" /> 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> </Button>
<div className="flex flex-col"> <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} Chat with Customer {chat.buyerId}
</h3> </h3>
{chat.telegramUsername && ( {chat.telegramUsername && (
<span className="text-sm text-muted-foreground"> <span className={cn(
"text-muted-foreground",
isTouchDevice ? "text-base" : "text-sm"
)}>
@{chat.telegramUsername} @{chat.telegramUsername}
</span> </span>
)} )}
@@ -568,7 +600,13 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} /> <BuyerOrderInfo buyerId={chat.buyerId} chatId={chatId} />
</div> </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 ? ( {chat.messages.length === 0 ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<p className="text-muted-foreground">No messages yet. Send one to start the conversation.</p> <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", "flex mb-4",
msg.sender === "vendor" ? "justify-end" : "justify-start" msg.sender === "vendor" ? "justify-end" : "justify-start"
)} )}
role="article"
aria-label={`Message from ${msg.sender === "vendor" ? "you" : "customer"}`}
> >
<div <div
className={cn( className={cn(
@@ -592,17 +632,17 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
> >
<div className="flex items-center space-x-2 mb-1"> <div className="flex items-center space-x-2 mb-1">
{msg.sender === "buyer" && ( {msg.sender === "buyer" && (
<Avatar className="h-6 w-6"> <Avatar className="h-6 w-6" aria-label="Customer avatar">
<AvatarFallback className="text-xs"> <AvatarFallback className="text-xs">
{chat.buyerId.slice(0, 2).toUpperCase()} {chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </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 })} {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
</span> </span>
</div> </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 */} {/* Show attachments if any */}
{msg.attachments && msg.attachments.length > 0 && ( {msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
@@ -618,10 +658,19 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
key={`attachment-${attachmentIndex}`} key={`attachment-${attachmentIndex}`}
className="rounded-md overflow-hidden bg-background/20 p-1" className="rounded-md overflow-hidden bg-background/20 p-1"
onClick={() => handleImageClick(attachment, messageIndex, attachmentIndex)} 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"> <div className="flex justify-between items-center mb-1">
<span className="text-xs opacity-70 flex items-center"> <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} {fileName}
</span> </span>
<a <a
@@ -631,8 +680,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs opacity-70 hover:opacity-100" className="text-xs opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()} 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> </a>
</div> </div>
<img <img
@@ -647,11 +697,11 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
) : ( ) : (
// Render file attachment // 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 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)} {getFileIcon(attachment)}
</div> </div>
<div className="flex-1 min-w-0"> <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} {fileName}
</div> </div>
</div> </div>
@@ -661,8 +711,9 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="ml-2 p-1 rounded-sm hover:bg-background/50" 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> </a>
</div> </div>
); );
@@ -676,21 +727,44 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </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"> <form onSubmit={handleSendMessage} className="flex space-x-2">
<Input <Input
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..." placeholder="Type your message..."
disabled={sending} 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} onKeyDown={handleKeyDown}
autoFocus 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" />} {sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button> </Button>
</form> </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> </div>
{/* Update the image viewer modal */} {/* Update the image viewer modal */}

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View File

@@ -17,3 +17,25 @@ export function useIsMobile() {
return !!isMobile 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
}

View File

@@ -1,4 +1,4 @@
{ {
"commitHash": "03a2e37", "commitHash": "bfc6001",
"buildTime": "2025-10-16T20:09:52.215Z" "buildTime": "2025-10-22T16:52:33.065Z"
} }