fix(a11y): ARIA labels and theme tokens for ListingMap (GOO-108)

- Map container: role="region" + aria-label="Bản đồ bất động sản"
- Price marker buttons: aria-label with price/title/address, aria-pressed for selection state
- Popup container: role="dialog" + aria-label with property title
- NavigationControl buttons: Vietnamese aria-labels patched on map load
- Listing-count overlay: bg-card/90 text-card-foreground + aria-live (was bg-white/90)
- Empty-state overlay: role="status" + bg-card/60 (was bg-white/60), dark-mode safe

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 10:17:41 +07:00
parent 0168f1f6f5
commit 1d26393f16

View File

@@ -94,6 +94,18 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
// Patch ARIA labels onto Mapbox's auto-generated navigation buttons (Vietnamese)
map.once('load', () => {
const container = mapContainerRef.current;
if (!container) return;
const zoomIn = container.querySelector('.mapboxgl-ctrl-zoom-in') as HTMLButtonElement | null;
const zoomOut = container.querySelector('.mapboxgl-ctrl-zoom-out') as HTMLButtonElement | null;
const compass = container.querySelector('.mapboxgl-ctrl-compass') as HTMLButtonElement | null;
if (zoomIn) zoomIn.setAttribute('aria-label', 'Phóng to');
if (zoomOut) zoomOut.setAttribute('aria-label', 'Thu nhỏ');
if (compass) compass.setAttribute('aria-label', 'Đặt lại hướng bắc');
});
mapRef.current = map;
return () => {
@@ -128,10 +140,21 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
el.className = 'mapbox-price-marker';
const isSelected = selectedListingId === marker.listing.id;
const span = document.createElement('span');
if (isSelected) span.className = 'selected';
if (isSelected) {
span.className = 'selected';
el.setAttribute('aria-pressed', 'true');
} else {
el.setAttribute('aria-pressed', 'false');
}
span.textContent = formatPrice(marker.listing.priceVND);
el.appendChild(span);
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
const { property } = marker.listing;
const address = [property.district, property.city].filter(Boolean).join(', ');
el.setAttribute(
'aria-label',
`${formatPrice(marker.listing.priceVND)} VND ${property.title}${address ? `, ${address}` : ''}`,
);
el.addEventListener('click', (e) => {
e.stopPropagation();
@@ -157,6 +180,8 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
const container = document.createElement('div');
container.setAttribute('role', 'dialog');
container.setAttribute('aria-label', `Chi tiết: ${listing.property.title}`);
container.style.cssText = 'font-family:system-ui,sans-serif;background:hsl(var(--card));color:hsl(var(--card-foreground));padding:8px;border-radius:6px;';
if ((listing.property.media?.length ?? 0) > 0) {
@@ -188,7 +213,7 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
const areaTag = document.createElement('span');
areaTag.style.cssText = tagStyle;
areaTag.textContent = `${listing.property.areaM2} m\u00B2`;
areaTag.textContent = `${listing.property.areaM2} m²`;
details.appendChild(areaTag);
if (listing.property.bedrooms != null) {
@@ -208,7 +233,7 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
const link = document.createElement('a');
link.href = `/listings/${listing.id}`;
link.style.cssText = 'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(var(--primary));text-decoration:none;';
link.textContent = 'Xem chi ti\u1EBFt \u2192';
link.textContent = 'Xem chi tiết →';
container.appendChild(link);
return container;
@@ -228,7 +253,11 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
return (
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[300px] md:h-[500px]'}`}>
<div
role="region"
aria-label="Bản đồ bất động sản"
className={`relative overflow-hidden rounded-lg border ${className || 'h-[300px] md:h-[500px]'}`}
>
<div ref={mapContainerRef} className="h-full w-full" />
{/* Fallback when no Mapbox token */}
@@ -246,13 +275,20 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
)}
{/* Listing count overlay */}
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
<div
aria-live="polite"
aria-atomic="true"
className="absolute bottom-3 left-3 rounded bg-card/90 px-2 py-1 text-xs text-card-foreground shadow"
>
{markers.length} bất đng sản trên bản đ
</div>
{/* Empty state */}
{markers.length === 0 && hasToken && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
<div
role="status"
className="absolute inset-0 flex items-center justify-center bg-card/60"
>
<p className="text-muted-foreground">Không bất đng sản đ hiển thị trên bản đ</p>
</div>
)}