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 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";
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "03a2e37",
|
"commitHash": "bfc6001",
|
||||||
"buildTime": "2025-10-16T20:09:52.215Z"
|
"buildTime": "2025-10-22T16:52:33.065Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user