feat(a11y): Implement ARIA accessibility sweep and keyboard navigation
- Added visually hidden labels, IDs, and names to form inputs across all key components and pages (modals, directories, chatbots) - Added tabIndex and onKeyDown handlers to clickable table rows in OwnerProjectDetail for keyboard accessibility - Validated existing ARIA roles and Escape key bindings on modal components
This commit is contained in:
@@ -716,7 +716,10 @@ const Chatbot = (props) => {
|
||||
{/* Input */}
|
||||
<div className="p-4 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
|
||||
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-3">
|
||||
<label htmlFor="chat-input-inline" className="sr-only">Message</label>
|
||||
<input
|
||||
id="chat-input-inline"
|
||||
name="chat-input"
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -817,7 +820,10 @@ const Chatbot = (props) => {
|
||||
{/* Input */}
|
||||
<div className="p-3 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
|
||||
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-2">
|
||||
<label htmlFor="chat-input-widget" className="sr-only">Message</label>
|
||||
<input
|
||||
id="chat-input-widget"
|
||||
name="chat-input"
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
||||
@@ -124,8 +124,11 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<label htmlFor="search-contractor-financial" className="sr-only">Search transactions</label>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<input
|
||||
id="search-contractor-financial"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search transactions..."
|
||||
value={searchTerm}
|
||||
@@ -156,7 +159,7 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${item.status === 'paid' || item.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
}`}>{item.status}</span>
|
||||
}`}>{item.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -199,8 +202,8 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide whitespace-nowrap ${item.status === 'paid' || item.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
@@ -33,8 +33,11 @@ export const AgentSelectionModal = ({ isOpen, onClose, onSelect }) => {
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<label htmlFor="search-agents" className="sr-only">Search agents</label>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<input
|
||||
id="search-agents"
|
||||
name="search-agents"
|
||||
type="text"
|
||||
placeholder="Search agents..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-zinc-100 dark:bg-black/20 border border-transparent focus:border-blue-500 rounded-xl text-sm outline-none transition-all dark:text-white"
|
||||
|
||||
@@ -18,9 +18,9 @@ const StatusBadge = ({ status }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const InputGroup = ({ label, children }) => (
|
||||
const InputGroup = ({ label, children, htmlFor }) => (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest ml-0.5">{label}</label>
|
||||
<label htmlFor={htmlFor} className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest ml-0.5">{label}</label>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -256,6 +256,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
|
||||
// Helper for Inputs
|
||||
const RenderInput = ({ label, field, type = "text", placeholder, options, locked = false }) => {
|
||||
const inputId = `prop-${field}`;
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<InputGroup label={label}>
|
||||
@@ -277,9 +279,11 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroup label={label}>
|
||||
<InputGroup label={label} htmlFor={inputId}>
|
||||
{options ? (
|
||||
<select
|
||||
id={inputId}
|
||||
name={field}
|
||||
value={formData[field]}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
|
||||
className="w-full bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-lg py-2 px-2 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 appearance-none"
|
||||
@@ -292,6 +296,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id={inputId}
|
||||
name={field}
|
||||
type={type}
|
||||
value={formData[field]}
|
||||
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
|
||||
@@ -374,10 +380,10 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
{/* Status Selector */}
|
||||
{isEditing && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-zinc-500 tracking-widest ml-1">Canvassing Status</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<span id="status-label" className="block text-[10px] uppercase font-bold text-zinc-500 tracking-widest ml-1">Canvassing Status</span>
|
||||
<div role="group" aria-labelledby="status-label" className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{["Neutral", "Hot Lead", "Renovated", "Customer", "Not Interested"].map(s => (
|
||||
<button key={s} onClick={() => setFormData({ ...formData, status: s })} className={`px-1 py-2 rounded text-[10px] font-bold border transition-all ${formData.status === s ? 'bg-zinc-900 dark:bg-white text-white dark:text-black' : 'bg-transparent border-zinc-200 dark:border-white/10 text-zinc-500'}`}>{s}</button>
|
||||
<button key={s} aria-pressed={formData.status === s} onClick={() => setFormData({ ...formData, status: s })} className={`px-1 py-2 rounded text-[10px] font-bold border transition-all ${formData.status === s ? 'bg-zinc-900 dark:bg-white text-white dark:text-black' : 'bg-transparent border-zinc-200 dark:border-white/10 text-zinc-500'}`}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,10 +557,12 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
{/* SECTION 6: NOTES */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-zinc-200 dark:border-white/5 text-zinc-500">
|
||||
<Edit2 size={16} /> <h3 className="text-xs font-black uppercase tracking-widest">Field Notes</h3>
|
||||
<Edit2 size={16} /> <h3 className="text-xs font-black uppercase tracking-widest"><label htmlFor="prop-notes">Field Notes</label></h3>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
id="prop-notes"
|
||||
name="notes"
|
||||
className="w-full h-32 bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/10 rounded-xl p-3 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 resize-none"
|
||||
value={formData.notes}
|
||||
placeholder="Add notes..."
|
||||
@@ -615,6 +623,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
|
||||
<span className="text-xl">+</span>
|
||||
<span className="text-xs uppercase tracking-widest">Upload Photo</span>
|
||||
<input
|
||||
id="photo-upload"
|
||||
name="photo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
|
||||
@@ -43,9 +43,11 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-zinc-400">{field.label}</label>
|
||||
<label htmlFor={field.name} className="text-xs font-bold uppercase tracking-wider text-zinc-400">{field.label}</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
className="w-full bg-[#18181b] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-[#00f0ff] focus:ring-1 focus:ring-[#00f0ff] transition-all"
|
||||
rows={4}
|
||||
placeholder={field.placeholder || ''}
|
||||
@@ -55,6 +57,8 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
className="w-full bg-[#18181b] border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-[#00f0ff] focus:ring-1 focus:ring-[#00f0ff] transition-all"
|
||||
required={field.required}
|
||||
value={formData[field.name] || ''}
|
||||
@@ -65,6 +69,8 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
</select>
|
||||
) : field.type === 'file' ? (
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="w-full text-sm text-zinc-400 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border file:border-white/10 file:text-sm file:font-bold file:bg-[#18181b] file:text-white hover:file:bg-white/5 transition-all"
|
||||
@@ -73,6 +79,8 @@ const CreateItemModal = ({ isOpen, onClose, title, icon: Icon, iconColor = "text
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type={field.type || 'text'}
|
||||
className="w-full bg-[#18181b] border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-[#00f0ff] focus:ring-1 focus:ring-[#00f0ff] transition-all"
|
||||
placeholder={field.placeholder || ''}
|
||||
|
||||
@@ -183,8 +183,11 @@ const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<label htmlFor="search-owner-financial" className="sr-only">Search transactions</label>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<input
|
||||
id="search-owner-financial"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search by name, ID..."
|
||||
value={searchTerm}
|
||||
|
||||
+8
-5
@@ -125,8 +125,11 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<label htmlFor="search-vendor-financial" className="sr-only">Search invoices</label>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<input
|
||||
id="search-vendor-financial"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search invoices..."
|
||||
value={searchTerm}
|
||||
@@ -158,7 +161,7 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
||||
: invoice.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400'
|
||||
}`}>{invoice.status}</span>
|
||||
}`}>{invoice.status}</span>
|
||||
<span className="text-xs text-zinc-500">Due: {invoice.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,10 +213,10 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide whitespace-nowrap ${invoice.status === 'paid'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: invoice.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: invoice.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400'
|
||||
}`}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user