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.NavigationControl(), 'top-right');
|
||||||
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-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;
|
mapRef.current = map;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -128,10 +140,21 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
el.className = 'mapbox-price-marker';
|
el.className = 'mapbox-price-marker';
|
||||||
const isSelected = selectedListingId === marker.listing.id;
|
const isSelected = selectedListingId === marker.listing.id;
|
||||||
const span = document.createElement('span');
|
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);
|
span.textContent = formatPrice(marker.listing.priceVND);
|
||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
|
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) => {
|
el.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -157,6 +180,8 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
|
|
||||||
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
|
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
|
||||||
const container = document.createElement('div');
|
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;';
|
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) {
|
if ((listing.property.media?.length ?? 0) > 0) {
|
||||||
@@ -188,7 +213,7 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
|
|
||||||
const areaTag = document.createElement('span');
|
const areaTag = document.createElement('span');
|
||||||
areaTag.style.cssText = tagStyle;
|
areaTag.style.cssText = tagStyle;
|
||||||
areaTag.textContent = `${listing.property.areaM2} m\u00B2`;
|
areaTag.textContent = `${listing.property.areaM2} m²`;
|
||||||
details.appendChild(areaTag);
|
details.appendChild(areaTag);
|
||||||
|
|
||||||
if (listing.property.bedrooms != null) {
|
if (listing.property.bedrooms != null) {
|
||||||
@@ -208,7 +233,7 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = `/listings/${listing.id}`;
|
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.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);
|
container.appendChild(link);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
@@ -228,7 +253,11 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
|
||||||
return (
|
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" />
|
<div ref={mapContainerRef} className="h-full w-full" />
|
||||||
|
|
||||||
{/* Fallback when no Mapbox token */}
|
{/* Fallback when no Mapbox token */}
|
||||||
@@ -246,13 +275,20 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Listing count overlay */}
|
{/* 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 đồ
|
{markers.length} bất động sản trên bản đồ
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{markers.length === 0 && hasToken && (
|
{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>
|
<p className="text-muted-foreground">Không có bất động sản để hiển thị trên bản đồ</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user