fix(a11y): add accessibility and UX polish to DirectoryPicker

- Add role="dialog", aria-modal, aria-label to the modal panel
- Add aria-label to close button, home button
- Add aria-hidden to decorative icons (chevrons, backdrop)
- Add role="status" to loading spinner with sr-only label
- Add role="alert" to error state
- Add aria-current="location" to active breadcrumb segment
- Wrap breadcrumb in nav landmark with aria-label
- Add Escape key handler to dismiss the modal
- Auto-focus the modal panel on open
- Add focus-visible ring styles to all interactive elements
  (matches existing focus-visible:ring-2 ring-accent/40 pattern)
- Increase breadcrumb button padding (px-1.5 py-1) for better
  touch targets
- Increase directory entry padding (py-2.5) for touch comfort
- Add active:bg-hover/70 pressed state on directory entries
- Add active:bg-accent/80 pressed state on select button
This commit is contained in:
Gergo Magyar 2026-05-27 09:14:29 +01:00
parent 50c9defb34
commit f25ce8db73

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Folder, ChevronRight, Home, X, Loader2, AlertCircle, Check } from '@/lib/lucide-icons';
import { listDirectories, type DirEntry } from '../services/backend-client';
import { useTranslation } from 'react-i18next';
@ -17,6 +17,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isVisible, setIsVisible] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const fetchEntries = useCallback(async (dir: string) => {
setLoading(true);
@ -41,6 +42,24 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
}
}, [open, initialDir, fetchEntries]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
useEffect(() => {
if (open && panelRef.current) {
panelRef.current.focus();
}
}, [open]);
if (!open) return null;
const segments = currentDir.split('/').filter(Boolean);
@ -59,11 +78,17 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-hidden="true"
/>
<div
ref={panelRef}
data-testid="directory-picker-modal"
className={`relative mx-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
role="dialog"
aria-modal="true"
aria-label={t('repoAnalyzer.directoryPicker.title')}
tabIndex={-1}
className={`relative mx-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 outline-none ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
style={{ maxHeight: '70vh' }}
>
{/* Header */}
@ -73,27 +98,33 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
</h3>
<button
onClick={onClose}
className="p-1 text-text-muted transition-colors hover:text-text-primary"
aria-label="Close"
className="rounded-md p-1.5 text-text-muted transition-colors hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 overflow-x-auto border-b border-border-subtle bg-elevated/50 px-5 py-2.5">
<nav
aria-label="Directory breadcrumb"
className="flex items-center gap-1 overflow-x-auto border-b border-border-subtle bg-elevated/50 px-5 py-2"
>
<button
data-testid="directory-picker-home"
onClick={() => navigateTo('/')}
className="shrink-0 rounded p-0.5 text-text-muted transition-colors hover:text-accent"
aria-label="Root directory"
className="shrink-0 rounded-md p-1.5 text-text-muted transition-colors hover:text-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none"
>
<Home className="h-3.5 w-3.5" />
</button>
{segments.map((seg, i) => (
<span key={i} className="flex shrink-0 items-center gap-1">
<ChevronRight className="h-3 w-3 text-text-muted/50" />
<ChevronRight className="h-3 w-3 text-text-muted/50" aria-hidden="true" />
<button
onClick={() => handleBreadcrumbClick(i)}
className={`rounded px-1 py-0.5 font-mono text-xs transition-colors ${
aria-current={i === segments.length - 1 ? 'location' : undefined}
className={`rounded-md px-1.5 py-1 font-mono text-xs transition-colors focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none ${
i === segments.length - 1
? 'font-medium text-text-primary'
: 'text-text-muted hover:text-accent'
@ -103,25 +134,26 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
</button>
</span>
))}
</div>
</nav>
{/* Directory listing */}
<div data-testid="directory-listing" className="min-h-[200px] flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center justify-center py-12" role="status">
<Loader2 className="h-5 w-5 animate-spin text-text-muted" />
<span className="sr-only">Loading directories...</span>
</div>
)}
{error && !loading && (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<div className="flex flex-col items-center gap-2 py-12 text-center" role="alert">
<AlertCircle className="h-5 w-5 text-red-400" />
<p className="text-xs text-red-400">{error}</p>
<button
onClick={() =>
navigateTo(currentDir === '/' ? '/' : currentDir.replace(/\/[^/]+$/, '') || '/')
}
className="mt-1 text-xs text-text-muted underline hover:text-text-secondary"
className="mt-1 rounded-md px-2 py-1 text-xs text-text-muted underline transition-colors hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none"
>
{t('repoAnalyzer.directoryPicker.goBack')}
</button>
@ -144,13 +176,13 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
key={entry.name}
data-testid={`dir-entry-${entry.name}`}
onClick={() => navigateTo(target)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-colors hover:bg-hover"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-hover active:bg-hover/70 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none"
>
<Folder className="h-4 w-4 shrink-0 text-accent/70" />
<span className="truncate font-mono text-xs text-text-secondary">
{entry.name}
</span>
<ChevronRight className="ml-auto h-3 w-3 shrink-0 text-text-muted/40" />
<ChevronRight className="ml-auto h-3 w-3 shrink-0 text-text-muted/40" aria-hidden="true" />
</button>
);
})}
@ -164,7 +196,7 @@ export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: Directo
<button
data-testid="directory-picker-select"
onClick={() => onSelect(currentDir)}
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-xs font-medium text-white transition-colors hover:bg-accent/90"
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-xs font-medium text-white transition-colors hover:bg-accent/90 active:bg-accent/80 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface focus-visible:outline-none"
>
<Check className="h-3.5 w-3.5" />
{t('repoAnalyzer.directoryPicker.select')}