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:
@@ -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 có bất động sản để hiển thị trên bản đồ</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user