fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark) - Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger - Add aria-pressed on list/map/split view-mode toggle buttons - Add aria-expanded + aria-controls on mobile filter toggle button - Add role="status" + aria-label="Đang tải..." on Suspense fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -64,8 +64,49 @@ function SearchContent() {
|
||||
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
|
||||
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
||||
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const createSavedSearch = useCreateSavedSearch();
|
||||
|
||||
// Focus management for save-search dialog
|
||||
React.useEffect(() => {
|
||||
if (showSaveDialog) {
|
||||
saveNameInputRef.current?.focus();
|
||||
}
|
||||
}, [showSaveDialog]);
|
||||
|
||||
// Focus trap + Escape key for save-search dialog
|
||||
React.useEffect(() => {
|
||||
if (!showSaveDialog) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowSaveDialog(false);
|
||||
saveButtonRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const dialog = saveDialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, input, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showSaveDialog]);
|
||||
|
||||
const handleMarkerClick = (listing: ListingDetail) => {
|
||||
setSelectedListingId(listing.id);
|
||||
};
|
||||
@@ -163,11 +204,15 @@ function SearchContent() {
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={saveButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||
aria-expanded={showSaveDialog}
|
||||
aria-controls="save-search-dialog"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
Lưu tìm kiếm
|
||||
@@ -175,10 +220,17 @@ function SearchContent() {
|
||||
|
||||
{/* Save search dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div
|
||||
id="save-search-dialog"
|
||||
ref={saveDialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="save-search-heading"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||
@@ -188,6 +240,7 @@ function SearchContent() {
|
||||
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
||||
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
||||
<input
|
||||
ref={saveNameInputRef}
|
||||
id="save-search-name"
|
||||
type="text"
|
||||
value={saveName}
|
||||
@@ -246,8 +299,9 @@ function SearchContent() {
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-pressed={viewMode === 'list'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
Danh sách
|
||||
@@ -256,8 +310,9 @@ function SearchContent() {
|
||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('map')}
|
||||
aria-pressed={viewMode === 'map'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
Bản đồ
|
||||
@@ -267,8 +322,9 @@ function SearchContent() {
|
||||
size="sm"
|
||||
className="hidden lg:flex"
|
||||
onClick={() => setViewMode('split')}
|
||||
aria-pressed={viewMode === 'split'}
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
Chia đôi
|
||||
@@ -280,8 +336,10 @@ function SearchContent() {
|
||||
size="sm"
|
||||
className="lg:hidden"
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
aria-expanded={showMobileFilters}
|
||||
aria-controls="mobile-filter-panel"
|
||||
>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Bộ lọc
|
||||
@@ -305,7 +363,7 @@ function SearchContent() {
|
||||
|
||||
{/* Mobile filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
@@ -392,7 +450,11 @@ export default function SearchPage() {
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Đang tải..."
|
||||
className="flex min-h-[400px] items-center justify-center"
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user